Add per-list permission management to roles.

This commit is contained in:
Kailash Nadh 2024-06-23 22:50:24 +05:30
parent 19527f97eb
commit 612c1d6eac
16 changed files with 330 additions and 87 deletions

View file

@ -16,8 +16,6 @@ import (
// install runs the first time setup of setting up the database. // install runs the first time setup of setting up the database.
func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) { func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) {
consts := initConstants()
qMap := readQueries(queryFilePath, db, fs) qMap := readQueries(queryFilePath, db, fs)
fmt.Println("") fmt.Println("")
@ -63,6 +61,43 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
// Load the queries. // Load the queries.
q := prepareQueries(qMap, db, ko) q := prepareQueries(qMap, db, ko)
// Sample list.
defList, optinList := installLists(q)
// Sample subscribers.
installSubs(defList, optinList, q)
// Templates.
campTplID, archiveTplID := installTemplates(q)
// Sample campaign.
installCampaign(campTplID, archiveTplID, q)
// Super admin role.
installUser(q)
lo.Printf("setup complete")
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))
}
// installSchema executes the SQL schema and creates the necessary tables and types.
func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error {
q, err := fs.Read("/schema.sql")
if err != nil {
return err
}
if _, err := db.Exec(string(q)); err != nil {
return err
}
// Insert the current migration version.
return recordMigrationVersion(curVer, db)
}
func installUser(q *models.Queries) {
consts := initConstants()
// Super admin role. // Super admin role.
perms := []string{} perms := []string{}
for p := range consts.Permissions { for p := range consts.Permissions {
@ -84,8 +119,9 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, "enabled"); err != nil { if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, "enabled"); err != nil {
lo.Fatalf("error creating superadmin user: %v", err) lo.Fatalf("error creating superadmin user: %v", err)
} }
}
// Sample list. func installLists(q *models.Queries) (int, int) {
var ( var (
defList int defList int
optinList int optinList int
@ -111,13 +147,17 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
lo.Fatalf("error creating list: %v", err) lo.Fatalf("error creating list: %v", err)
} }
return defList, optinList
}
func installSubs(defListID, optinListID int, q *models.Queries) {
// Sample subscriber. // Sample subscriber.
if _, err := q.UpsertSubscriber.Exec( if _, err := q.UpsertSubscriber.Exec(
uuid.Must(uuid.NewV4()), uuid.Must(uuid.NewV4()),
"john@example.com", "john@example.com",
"John Doe", "John Doe",
`{"type": "known", "good": true, "city": "Bengaluru"}`, `{"type": "known", "good": true, "city": "Bengaluru"}`,
pq.Int64Array{int64(defList)}, pq.Int64Array{int64(defListID)},
models.SubscriptionStatusUnconfirmed, models.SubscriptionStatusUnconfirmed,
true); err != nil { true); err != nil {
lo.Fatalf("Error creating subscriber: %v", err) lo.Fatalf("Error creating subscriber: %v", err)
@ -127,12 +167,14 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
"anon@example.com", "anon@example.com",
"Anon Doe", "Anon Doe",
`{"type": "unknown", "good": true, "city": "Bengaluru"}`, `{"type": "unknown", "good": true, "city": "Bengaluru"}`,
pq.Int64Array{int64(optinList)}, pq.Int64Array{int64(optinListID)},
models.SubscriptionStatusUnconfirmed, models.SubscriptionStatusUnconfirmed,
true); err != nil { true); err != nil {
lo.Fatalf("error creating subscriber: %v", err) lo.Fatalf("error creating subscriber: %v", err)
} }
}
func installTemplates(q *models.Queries) (int, int) {
// Default campaign template. // Default campaign template.
campTpl, err := fs.Get("/static/email-templates/default.tpl") campTpl, err := fs.Get("/static/email-templates/default.tpl")
if err != nil { if err != nil {
@ -158,6 +200,20 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
lo.Fatalf("error creating default campaign template: %v", err) lo.Fatalf("error creating default campaign template: %v", err)
} }
// Sample tx template.
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
if err != nil {
lo.Fatalf("error reading default e-mail template: %v", err)
}
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
lo.Fatalf("error creating sample transactional template: %v", err)
}
return campTplID, archiveTplID
}
func installCampaign(campTplID, archiveTplID int, q *models.Queries) {
// Sample campaign. // Sample campaign.
if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()), if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()),
models.CampaignTypeRegular, models.CampaignTypeRegular,
@ -189,33 +245,6 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
lo.Fatalf("error creating sample campaign: %v", err) lo.Fatalf("error creating sample campaign: %v", err)
} }
// Sample tx template.
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
if err != nil {
lo.Fatalf("error reading default e-mail template: %v", err)
}
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
lo.Fatalf("error creating sample transactional template: %v", err)
}
lo.Printf("setup complete")
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))
}
// installSchema executes the SQL schema and creates the necessary tables and types.
func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error {
q, err := fs.Read("/schema.sql")
if err != nil {
return err
}
if _, err := db.Exec(string(q)); err != nil {
return err
}
// Insert the current migration version.
return recordMigrationVersion(curVer, db)
} }
// recordMigrationVersion inserts the given version (of DB migration) into the // recordMigrationVersion inserts the given version (of DB migration) into the

View file

@ -69,7 +69,7 @@ func handleUpdateRole(c echo.Context) error {
} }
// Validate. // Validate.
r.Name = strings.TrimSpace(r.Name) r.Name.String = strings.TrimSpace(r.Name.String)
out, err := app.core.UpdateRole(id, r) out, err := app.core.UpdateRole(id, r)
if err != nil { if err != nil {
@ -99,7 +99,7 @@ func handleDeleteRole(c echo.Context) error {
func validateRole(r models.Role, app *App) error { func validateRole(r models.Role, app *App) error {
// Validate fields. // Validate fields.
if !strHasLen(r.Name, 3, stdInputMaxLen) { if !strHasLen(r.Name.String, 2, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name")) return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name"))
} }
@ -109,5 +109,13 @@ func validateRole(r models.Role, app *App) error {
} }
} }
for _, l := range r.Lists {
for _, p := range l.Permissions {
if p != "list:get" && p != "list:manage" {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "list permissions"))
}
}
}
return nil return nil
} }

View file

@ -168,7 +168,7 @@ export default Vue.extend({
mounted() { mounted() {
// Lists is required across different views. On app load, fetch the lists // Lists is required across different views. On app load, fetch the lists
// and have them in the store. // and have them in the store.
if (this.$can('lists:get')) { if (this.$can('lists:get_all')) {
this.$api.getLists({ minimal: true, per_page: 'all' }); this.$api.getLists({ minimal: true, per_page: 'all' });
} }

View file

@ -3,7 +3,7 @@
<b-menu-item :to="{ name: 'dashboard' }" tag="router-link" :active="activeItem.dashboard" <b-menu-item :to="{ name: 'dashboard' }" tag="router-link" :active="activeItem.dashboard"
icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')" /><!-- dashboard --> icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')" /><!-- dashboard -->
<b-menu-item v-if="$can('lists:get')" :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists" <b-menu-item v-if="$can('lists:get_all')" :expanded="activeGroup.lists" :active="activeGroup.lists" data-cy="lists"
@update:active="(state) => toggleGroup('lists', state)" icon="format-list-bulleted-square" @update:active="(state) => toggleGroup('lists', state)" icon="format-list-bulleted-square"
:label="$t('globals.terms.lists')"> :label="$t('globals.terms.lists')">
<b-menu-item :to="{ name: 'lists' }" tag="router-link" :active="activeItem.lists" data-cy="all-lists" <b-menu-item :to="{ name: 'lists' }" tag="router-link" :active="activeItem.lists" data-cy="all-lists"

View file

@ -58,7 +58,7 @@
<b-button @click="$parent.close()"> <b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }} {{ $t('globals.buttons.close') }}
</b-button> </b-button>
<b-button v-if="$can('lists:manage')" native-type="submit" type="is-primary" :loading="loading.lists" <b-button v-if="$can('lists:manage_all')" native-type="submit" type="is-primary" :loading="loading.lists"
data-cy="btn-save"> data-cy="btn-save">
{{ $t('globals.buttons.save') }} {{ $t('globals.buttons.save') }}
</b-button> </b-button>

View file

@ -8,7 +8,7 @@
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-field v-if="$can('lists:manage')" expanded> <b-field v-if="$can('lists:manage_all')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new"> <b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }} {{ $t('globals.buttons.new') }}
</b-button> </b-button>

View file

@ -18,23 +18,86 @@
<b-input :disabled="disabled" :maxlength="200" v-model="form.name" name="name" :ref="'focus'" required /> <b-input :disabled="disabled" :maxlength="200" v-model="form.name" name="name" :ref="'focus'" required />
</b-field> </b-field>
<p class="has-text-right" v-if="!disabled"> <div v-if="!disabled" class="box">
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a> <h5>{{ $t('users.listPerms') }}</h5>
</p> <div class="mb-5">
<div class="columns">
<div class="column is-9">
<b-select :placeholder="$tc('globals.terms.list')" v-model="form.curList" name="list"
:disabled="disabled" expanded class="mb-3">
<template v-for="l in filteredLists">
<option :value="l.id" :key="l.id">
{{ l.name }}
</option>
</template>
</b-select>
<b-table :data="serverConfig.permissions"> </div>
<b-table-column v-slot="props" field="group" label="Group"> <div class="column">
{{ $tc(`globals.terms.${props.row.group}`) }} <b-button @click="onAddListPerm" :disabled="!form.curList" class="is-primary" expanded>{{
</b-table-column> $t('globals.buttons.add')
}}</b-button>
<b-table-column v-slot="props" field="permissions" label="Permissions"> </div>
<div v-for="p in props.row.permissions" :key="p">
<b-checkbox v-model="form.permissions[p]" :disabled="disabled">
{{ p }}
</b-checkbox>
</div> </div>
</b-table-column> <span
</b-table> v-if="form.lists.length > 0 && (form.permissions['lists:get_all'] || form.permissions['lists:manage_all'])"
class="is-size-6 has-text-danger">
<b-icon icon="warning-empty" />
{{ $t('users.listPermsWarning') }}
</span>
</div>
<b-table :data="form.lists">
<b-table-column v-slot="props" field="name" :label="$tc('globals.terms.list')">
<router-link :to="`/lists/${props.row.id}`" target="_blank">
{{ props.row.name }}
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="permissions" :label="$t('users.perms')" width="40%">
<b-checkbox v-model="props.row.permissions" native-value="list:get">
{{ $t('globals.buttons.view') }}
</b-checkbox>
<b-checkbox v-model="props.row.permissions" native-value="list:manage">
{{ $t('globals.buttons.manage') }}
</b-checkbox>
</b-table-column>
<b-table-column v-slot="props" width="10%">
<a href="#" @click.prevent="onDeleteListPerm(props.row.id)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
</b-table>
</div>
<div class="box">
<div class="columns">
<div class="column is-7">
<h5 class="mb-0">{{ $t('users.perms') }}</h5>
</div>
<div class="column has-text-right" v-if="!disabled">
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
</div>
</div>
<b-table :data="serverConfig.permissions">
<b-table-column v-slot="props" field="group" :label="$t('users.roleGroup')">
{{ $tc(`globals.terms.${props.row.group}`) }}
</b-table-column>
<b-table-column v-slot="props" field="permissions" label="Permissions">
<div v-for="p in props.row.permissions" :key="p">
<b-checkbox v-model="form.permissions" :native-value="p" :disabled="disabled">
{{ p }}
</b-checkbox>
</div>
</b-table-column>
</b-table>
</div>
<a href="https://listmonk.app/docs/roles-and-permissions" target="_blank" rel="noopener noreferrer"> <a href="https://listmonk.app/docs/roles-and-permissions" target="_blank" rel="noopener noreferrer">
<b-icon icon="link-variant" /> {{ $t('globals.buttons.learnMore') }} <b-icon icon="link-variant" /> {{ $t('globals.buttons.learnMore') }}
</a> </a>
@ -73,7 +136,9 @@ export default Vue.extend({
return { return {
// Binds form input values. // Binds form input values.
form: { form: {
name: '', curList: null,
lists: [],
name: this.$t('users.newRole'),
permissions: {}, permissions: {},
}, },
hasToggle: false, hasToggle: false,
@ -82,6 +147,18 @@ export default Vue.extend({
}, },
methods: { methods: {
onAddListPerm() {
const list = this.lists.results.find((l) => l.id === this.form.curList);
this.form.lists.push({ id: list.id, name: list.name, permissions: ["list:get", "list:manage"] });
this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
},
onDeleteListPerm(id) {
this.form.lists = this.form.lists.filter((p) => p.id !== id);
this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
},
onSubmit() { onSubmit() {
if (this.isEditing) { if (this.isEditing) {
this.updateRole(); this.updateRole();
@ -93,21 +170,26 @@ export default Vue.extend({
onToggleSelect() { onToggleSelect() {
if (this.hasToggle) { if (this.hasToggle) {
this.form.permissions = {}; this.form.permissions = [];
} else { } else {
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => { this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
item.permissions.forEach((p) => { item.permissions.forEach((p) => {
acc[p] = true; acc.push(p);
}); });
return acc; return acc;
}, {}); }, []);
} }
this.hasToggle = !this.hasToggle; this.hasToggle = !this.hasToggle;
}, },
createRole() { createRole() {
const form = { ...this.form, permissions: Object.keys(this.form.permissions) }; const lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions })
return acc;
}, []);
const form = { name: this.form.name, permissions: this.form.permissions, lists: lists };
this.$api.createRole(form).then((data) => { this.$api.createRole(form).then((data) => {
this.$emit('finished'); this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name })); this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
@ -116,9 +198,12 @@ export default Vue.extend({
}, },
updateRole() { updateRole() {
const form = { const lists = this.form.lists.reduce((acc, item) => {
id: this.data.id, name: this.form.name, permissions: Object.keys(this.form.permissions).filter((key) => this.form.permissions[key] === true), acc.push({ id: item.id, permissions: item.permissions })
}; return acc;
}, []);
const form = { id: this.$props.data.id, name: this.form.name, permissions: this.form.permissions, lists: lists };
this.$api.updateRole(form).then((data) => { this.$api.updateRole(form).then((data) => {
this.$emit('finished'); this.$emit('finished');
this.$parent.close(); this.$parent.close();
@ -128,17 +213,23 @@ export default Vue.extend({
}, },
computed: { computed: {
...mapState(['loading', 'serverConfig']), ...mapState(['loading', 'serverConfig', 'lists']),
// Return the list of unselected lists.
filteredLists() {
if (!this.lists.results) {
return [];
}
const subIDs = this.form.lists.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
return this.lists.results.filter((l) => (!(l.id in subIDs)));
},
}, },
mounted() { mounted() {
this.form = { ...this.form, name: this.$props.data.name };
if (this.isEditing) { if (this.isEditing) {
this.form.permissions = this.$props.data.permissions.reduce((acc, key) => { this.form = { ...this.form, ...this.$props.data };
acc[key] = true;
return acc;
}, {});
// It's the superadmin role. Disable the form. // It's the superadmin role. Disable the form.
if (this.$props.data.id === 1 || !this.$can('roles:manage')) { if (this.$props.data.id === 1 || !this.$can('roles:manage')) {
@ -151,15 +242,18 @@ export default Vue.extend({
return acc; return acc;
} }
item.permissions.forEach((p) => { item.permissions.forEach((p) => {
if (p !== 'subscribers:sql_query') { if (p !== 'subscribers:sql_query' && !p.startsWith('lists:') && !p.startsWith('settings:')) {
acc[p] = true; acc.push(p);
} }
}); });
return acc; return acc;
}, {}); }, []);
} }
this.$nextTick(() => { this.$nextTick(() => {
if (this.filteredLists.length > 0) {
this.form.curList = this.filteredLists[0].id;
}
this.$refs.focus.focus(); this.$refs.focus.focus();
}); });
}, },

View file

@ -125,8 +125,9 @@ export default Vue.extend({
}, },
onCloneRole(name, item) { onCloneRole(name, item) {
this.$api.createRole({ name, permissions: item.permissions }).then(() => { this.$api.createRole({ name, permissions: item.permissions, lists: item.lists }).then(() => {
this.$api.getRoles(); this.$api.getRoles();
this.$utils.toast(this.$t('globals.messages.created', { name }));
}); });
}, },

View file

@ -152,6 +152,7 @@
"globals.buttons.save": "Save", "globals.buttons.save": "Save",
"globals.buttons.saveChanges": "Save changes", "globals.buttons.saveChanges": "Save changes",
"globals.buttons.view": "View", "globals.buttons.view": "View",
"globals.buttons.manage": "Manage",
"globals.days.0": "Sun", "globals.days.0": "Sun",
"globals.days.1": "Sun", "globals.days.1": "Sun",
"globals.days.2": "Mon", "globals.days.2": "Mon",
@ -600,6 +601,10 @@
"users.role": "Role | Roles", "users.role": "Role | Roles",
"users.roles": "Roles", "users.roles": "Roles",
"users.newRole": "New role", "users.newRole": "New role",
"users.listPerms": "List permissions",
"users.listPermsWarning": "lists:get_all or lists:manage_all are enabled which overrides per-list permissions",
"users.perms": "Permissions",
"users.roleGroup": "Group",
"users.loginOIDC": "Login with {name}", "users.loginOIDC": "Login with {name}",
"users.logout": "Logout", "users.logout": "Logout",
"users.profile": "Profile", "users.profile": "Profile",

View file

@ -1,6 +1,7 @@
package core package core
import ( import (
"encoding/json"
"net/http" "net/http"
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
@ -16,6 +17,17 @@ func (c *Core) GetRoles() ([]models.Role, error) {
c.i18n.Ts("globals.messages.errorFetching", "name", "{users.roles}", "error", pqErrMsg(err))) c.i18n.Ts("globals.messages.errorFetching", "name", "{users.roles}", "error", pqErrMsg(err)))
} }
// Unmarshall the nested list permissions, if any.
for n, r := range out {
if r.ListsRaw == nil {
continue
}
if err := json.Unmarshal(r.ListsRaw, &out[n].Lists); err != nil {
c.log.Printf("error unmarshalling list permissions for role %d: %v", r.ID, err)
}
}
return out, nil return out, nil
} }
@ -28,9 +40,55 @@ func (c *Core) CreateRole(r models.Role) (models.Role, error) {
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err))) c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
} }
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return out, nil return out, nil
} }
// UpsertListPermissions upserts permission for a role.
func (c *Core) UpsertListPermissions(roleID int, lp []models.ListPermission) error {
var (
listIDs = make([]int, 0, len(lp))
listPerms = make([][]string, 0, len(lp))
)
for _, p := range lp {
if len(p.Permissions) == 0 {
continue
}
listIDs = append(listIDs, p.ID)
// For the Postgres array unnesting query to work, all permissions arrays should
// have equal number of entries. Add "" in case there's only one of either list:get or list:manage
perms := make([]string, 2)
copy(perms[:], p.Permissions[:])
listPerms = append(listPerms, perms)
}
if _, err := c.q.UpsertListPermissions.Exec(roleID, pq.Array(listIDs), pq.Array(listPerms)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteListPermission deletes a list permission entry from a role.
func (c *Core) DeleteListPermission(roleID, listID int) error {
if _, err := c.q.DeleteListPermission.Exec(roleID, listID); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
}
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
}
return nil
}
// UpdateRole updates a given role. // UpdateRole updates a given role.
func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) { func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) {
var out models.Role var out models.Role
@ -44,6 +102,11 @@ func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) {
return models.Role{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.role}")) return models.Role{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.role}"))
} }
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return out, nil return out, nil
} }

View file

@ -41,12 +41,15 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
CREATE TABLE IF NOT EXISTS user_roles ( CREATE TABLE IF NOT EXISTS user_roles (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', parent_id INTEGER NULL REFERENCES user_roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}', permissions TEXT[] NOT NULL DEFAULT '{}',
name TEXT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name ON user_roles(LOWER(name)); CREATE UNIQUE INDEX IF NOT EXISTS user_roles_idx ON user_roles (parent_id, list_id);
CREATE UNIQUE INDEX IF NOT EXISTS user_roles_name_idx ON user_roles (name) WHERE name IS NOT NULL;
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,

View file

@ -168,11 +168,22 @@ type User struct {
HasPassword bool `db:"-" json:"-"` HasPassword bool `db:"-" json:"-"`
} }
type ListPermission struct {
ID int `json:"id"`
Name string `json:"name"`
Permissions pq.StringArray `json:"permissions"`
}
type Role struct { type Role struct {
Base Base
Name string `db:"name" json:"name"` Name null.String `db:"name" json:"name"`
Permissions pq.StringArray `db:"permissions" json:"permissions"` Permissions pq.StringArray `db:"permissions" json:"permissions"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
} }
// Subscriber represents an e-mail subscriber. // Subscriber represents an e-mail subscriber.

View file

@ -117,10 +117,12 @@ type Queries struct {
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"` GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
LoginUser *sqlx.Stmt `query:"login-user"` LoginUser *sqlx.Stmt `query:"login-user"`
CreateRole *sqlx.Stmt `query:"create-role"` CreateRole *sqlx.Stmt `query:"create-role"`
GetRoles *sqlx.Stmt `query:"get-roles"` GetRoles *sqlx.Stmt `query:"get-roles"`
UpdateRole *sqlx.Stmt `query:"update-role"` UpdateRole *sqlx.Stmt `query:"update-role"`
DeleteRole *sqlx.Stmt `query:"delete-role"` DeleteRole *sqlx.Stmt `query:"delete-role"`
UpsertListPermissions *sqlx.Stmt `query:"upsert-list-permissions"`
DeleteListPermission *sqlx.Stmt `query:"delete-list-permission"`
} }
// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions // CompileSubscriberQueryTpl takes an arbitrary WHERE expressions

View file

@ -3,8 +3,8 @@
"group": "lists", "group": "lists",
"permissions": "permissions":
[ [
"lists:get", "lists:get_all",
"lists:manage" "lists:manage_all"
] ]
}, },
{ {

View file

@ -1096,13 +1096,39 @@ UPDATE users SET name=$2, email=$3,
WHERE id=$1; WHERE id=$1;
-- name: get-roles -- name: get-roles
SELECT * FROM user_roles ORDER BY created_at; WITH mainroles AS (
SELECT ur.* FROM user_roles ur WHERE ur.parent_id IS NULL
),
listroles AS (
SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms
FROM user_roles ur
LEFT JOIN lists ON(lists.id = ur.list_id)
WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id
)
SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p
LEFT JOIN listroles l ON p.id = l.parent_id;
-- name: create-role -- name: create-role
INSERT INTO user_roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *; INSERT INTO user_roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *;
-- name: upsert-list-permissions
WITH d AS (
-- Delete lists that aren't included.
DELETE FROM user_roles WHERE parent_id = $1 AND list_id != ALL($2::INT[])
),
p AS (
-- Get (list_id, perms[]), (list_id, perms[])
SELECT UNNEST($2) AS list_id, JSONB_ARRAY_ELEMENTS(TO_JSONB($3::TEXT[][])) AS perms
)
INSERT INTO user_roles (parent_id, list_id, permissions)
SELECT $1, list_id, ARRAY_REMOVE(ARRAY(SELECT JSONB_ARRAY_ELEMENTS_TEXT(perms)), '') FROM p
ON CONFLICT (parent_id, list_id) DO UPDATE SET permissions = EXCLUDED.permissions;
-- name: delete-list-permission
DELETE FROM user_roles WHERE parent_id=$1 AND list_id=$2;
-- name: update-role -- name: update-role
UPDATE user_roles SET name=$2, permissions=$3 WHERE id=$1 RETURNING *; UPDATE user_roles SET name=$2, permissions=$3 WHERE id=$1 and parent_id IS NULL RETURNING *;
-- name: delete-role -- name: delete-role
DELETE FROM user_roles WHERE id=$1; DELETE FROM user_roles WHERE id=$1;

View file

@ -304,12 +304,15 @@ DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX idx_bounces_date ON bounces(
DROP TABLE IF EXISTS user_roles CASCADE; DROP TABLE IF EXISTS user_roles CASCADE;
CREATE TABLE user_roles ( CREATE TABLE user_roles (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', parent_id INTEGER NULL REFERENCES user_roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}', permissions TEXT[] NOT NULL DEFAULT '{}',
name TEXT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
DROP INDEX IF EXISTS idx_roles_name; CREATE UNIQUE INDEX idx_roles_name ON user_roles(LOWER(name)); CREATE UNIQUE INDEX user_roles_idx ON user_roles (parent_id, list_id);
CREATE UNIQUE INDEX user_roles_name_idx ON user_roles (name) WHERE name IS NOT NULL;
-- users -- users
DROP TABLE IF EXISTS users CASCADE; DROP TABLE IF EXISTS users CASCADE;
@ -326,8 +329,6 @@ CREATE TABLE users (
loggedin_at TIMESTAMP WITH TIME ZONE NULL, loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
-- CONSTRAINT user_role_id FOREIGN KEY (role_id) REFERENCES user_roles (id) ON DELETE RESTRICT
); );
-- user sessions -- user sessions