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.
func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) {
consts := initConstants()
qMap := readQueries(queryFilePath, db, fs)
fmt.Println("")
@ -63,6 +61,43 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
// Load the queries.
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.
perms := []string{}
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 {
lo.Fatalf("error creating superadmin user: %v", err)
}
}
// Sample list.
func installLists(q *models.Queries) (int, int) {
var (
defList 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)
}
return defList, optinList
}
func installSubs(defListID, optinListID int, q *models.Queries) {
// Sample subscriber.
if _, err := q.UpsertSubscriber.Exec(
uuid.Must(uuid.NewV4()),
"john@example.com",
"John Doe",
`{"type": "known", "good": true, "city": "Bengaluru"}`,
pq.Int64Array{int64(defList)},
pq.Int64Array{int64(defListID)},
models.SubscriptionStatusUnconfirmed,
true); err != nil {
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 Doe",
`{"type": "unknown", "good": true, "city": "Bengaluru"}`,
pq.Int64Array{int64(optinList)},
pq.Int64Array{int64(optinListID)},
models.SubscriptionStatusUnconfirmed,
true); err != nil {
lo.Fatalf("error creating subscriber: %v", err)
}
}
func installTemplates(q *models.Queries) (int, int) {
// Default campaign template.
campTpl, err := fs.Get("/static/email-templates/default.tpl")
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)
}
// 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.
if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()),
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)
}
// 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

View file

@ -69,7 +69,7 @@ func handleUpdateRole(c echo.Context) error {
}
// Validate.
r.Name = strings.TrimSpace(r.Name)
r.Name.String = strings.TrimSpace(r.Name.String)
out, err := app.core.UpdateRole(id, r)
if err != nil {
@ -99,7 +99,7 @@ func handleDeleteRole(c echo.Context) error {
func validateRole(r models.Role, app *App) error {
// 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"))
}
@ -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
}

View file

@ -168,7 +168,7 @@ export default Vue.extend({
mounted() {
// Lists is required across different views. On app load, fetch the lists
// 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' });
}

View file

@ -3,7 +3,7 @@
<b-menu-item :to="{ name: 'dashboard' }" tag="router-link" :active="activeItem.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"
:label="$t('globals.terms.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()">
{{ $t('globals.buttons.close') }}
</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">
{{ $t('globals.buttons.save') }}
</b-button>

View file

@ -8,7 +8,7 @@
</h1>
</div>
<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">
{{ $t('globals.buttons.new') }}
</b-button>

View file

@ -18,23 +18,86 @@
<b-input :disabled="disabled" :maxlength="200" v-model="form.name" name="name" :ref="'focus'" required />
</b-field>
<p class="has-text-right" v-if="!disabled">
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
</p>
<div v-if="!disabled" class="box">
<h5>{{ $t('users.listPerms') }}</h5>
<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">
<b-table-column v-slot="props" field="group" label="Group">
{{ $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[p]" :disabled="disabled">
{{ p }}
</b-checkbox>
</div>
<div class="column">
<b-button @click="onAddListPerm" :disabled="!form.curList" class="is-primary" expanded>{{
$t('globals.buttons.add')
}}</b-button>
</div>
</div>
</b-table-column>
</b-table>
<span
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">
<b-icon icon="link-variant" /> {{ $t('globals.buttons.learnMore') }}
</a>
@ -73,7 +136,9 @@ export default Vue.extend({
return {
// Binds form input values.
form: {
name: '',
curList: null,
lists: [],
name: this.$t('users.newRole'),
permissions: {},
},
hasToggle: false,
@ -82,6 +147,18 @@ export default Vue.extend({
},
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() {
if (this.isEditing) {
this.updateRole();
@ -93,21 +170,26 @@ export default Vue.extend({
onToggleSelect() {
if (this.hasToggle) {
this.form.permissions = {};
this.form.permissions = [];
} else {
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
item.permissions.forEach((p) => {
acc[p] = true;
acc.push(p);
});
return acc;
}, {});
}, []);
}
this.hasToggle = !this.hasToggle;
},
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.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
@ -116,9 +198,12 @@ export default Vue.extend({
},
updateRole() {
const form = {
id: this.data.id, name: this.form.name, permissions: Object.keys(this.form.permissions).filter((key) => this.form.permissions[key] === true),
};
const lists = this.form.lists.reduce((acc, item) => {
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.$emit('finished');
this.$parent.close();
@ -128,17 +213,23 @@ export default Vue.extend({
},
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() {
this.form = { ...this.form, name: this.$props.data.name };
if (this.isEditing) {
this.form.permissions = this.$props.data.permissions.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {});
this.form = { ...this.form, ...this.$props.data };
// It's the superadmin role. Disable the form.
if (this.$props.data.id === 1 || !this.$can('roles:manage')) {
@ -151,15 +242,18 @@ export default Vue.extend({
return acc;
}
item.permissions.forEach((p) => {
if (p !== 'subscribers:sql_query') {
acc[p] = true;
if (p !== 'subscribers:sql_query' && !p.startsWith('lists:') && !p.startsWith('settings:')) {
acc.push(p);
}
});
return acc;
}, {});
}, []);
}
this.$nextTick(() => {
if (this.filteredLists.length > 0) {
this.form.curList = this.filteredLists[0].id;
}
this.$refs.focus.focus();
});
},

View file

@ -125,8 +125,9 @@ export default Vue.extend({
},
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.$utils.toast(this.$t('globals.messages.created', { name }));
});
},

View file

@ -152,6 +152,7 @@
"globals.buttons.save": "Save",
"globals.buttons.saveChanges": "Save changes",
"globals.buttons.view": "View",
"globals.buttons.manage": "Manage",
"globals.days.0": "Sun",
"globals.days.1": "Sun",
"globals.days.2": "Mon",
@ -600,6 +601,10 @@
"users.role": "Role | Roles",
"users.roles": "Roles",
"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.logout": "Logout",
"users.profile": "Profile",

View file

@ -1,6 +1,7 @@
package core
import (
"encoding/json"
"net/http"
"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)))
}
// 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
}
@ -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)))
}
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
}
// 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.
func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) {
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}"))
}
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
}

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 (
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 '{}',
name TEXT NULL,
created_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 (
id TEXT NOT NULL PRIMARY KEY,

View file

@ -168,11 +168,22 @@ type User struct {
HasPassword bool `db:"-" json:"-"`
}
type ListPermission struct {
ID int `json:"id"`
Name string `json:"name"`
Permissions pq.StringArray `json:"permissions"`
}
type Role struct {
Base
Name string `db:"name" json:"name"`
Name null.String `db:"name" json:"name"`
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.

View file

@ -117,10 +117,12 @@ type Queries struct {
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
LoginUser *sqlx.Stmt `query:"login-user"`
CreateRole *sqlx.Stmt `query:"create-role"`
GetRoles *sqlx.Stmt `query:"get-roles"`
UpdateRole *sqlx.Stmt `query:"update-role"`
DeleteRole *sqlx.Stmt `query:"delete-role"`
CreateRole *sqlx.Stmt `query:"create-role"`
GetRoles *sqlx.Stmt `query:"get-roles"`
UpdateRole *sqlx.Stmt `query:"update-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

View file

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

View file

@ -1096,13 +1096,39 @@ UPDATE users SET name=$2, email=$3,
WHERE id=$1;
-- 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
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
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
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;
CREATE TABLE user_roles (
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 '{}',
name TEXT NULL,
created_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
DROP TABLE IF EXISTS users CASCADE;
@ -326,8 +329,6 @@ CREATE TABLE users (
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_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