@pramen/auth 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +142 -1
- package/dist/index.js +194 -10
- package/package.json +2 -2
- package/src/index.ts +209 -9
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HandlerContext } from "@pramen/server";
|
|
1
|
+
import type { HandlerContext, Policy } from "@pramen/server";
|
|
2
2
|
export declare const authSchema: {
|
|
3
3
|
auth_users: import("@pramen/server").EntityDef<{
|
|
4
4
|
username: {
|
|
@@ -8,10 +8,22 @@ export declare const authSchema: {
|
|
|
8
8
|
};
|
|
9
9
|
passwordHash: {
|
|
10
10
|
readonly type: "text";
|
|
11
|
+
} & {
|
|
12
|
+
readonly hidden: true;
|
|
11
13
|
};
|
|
12
14
|
roles: {
|
|
13
15
|
readonly type: "json";
|
|
14
16
|
};
|
|
17
|
+
email: {
|
|
18
|
+
readonly type: "text";
|
|
19
|
+
} & {
|
|
20
|
+
readonly unique: true;
|
|
21
|
+
};
|
|
22
|
+
active: {
|
|
23
|
+
readonly type: "boolean";
|
|
24
|
+
} & {
|
|
25
|
+
readonly default: true;
|
|
26
|
+
};
|
|
15
27
|
createdAt: {
|
|
16
28
|
readonly type: "integer";
|
|
17
29
|
};
|
|
@@ -106,3 +118,132 @@ export declare function createMagicLinkAuth(opts: MagicLinkOptions): {
|
|
|
106
118
|
};
|
|
107
119
|
}>;
|
|
108
120
|
};
|
|
121
|
+
/** Build admin + self-service handlers over a users table (default `auth_users`).
|
|
122
|
+
* Pass `table` to operate over your OWN authSchema-shaped table — e.g. one with an
|
|
123
|
+
* extra `tenants` column — without renaming it: the handlers manage username/roles/
|
|
124
|
+
* email/active/delete and ignore any extra columns. Spread the result into your
|
|
125
|
+
* handler map and gate it with the matching `authPolicies({ table })`. The table must
|
|
126
|
+
* have a `username` primary key and (for changeEmail/changePassword) `email`/
|
|
127
|
+
* `passwordHash` columns. */
|
|
128
|
+
export declare function createUserHandlers(opts?: {
|
|
129
|
+
table?: string;
|
|
130
|
+
}): {
|
|
131
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
132
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
133
|
+
listUsers: import("@pramen/server").Handler<{
|
|
134
|
+
limit?: number;
|
|
135
|
+
offset?: number;
|
|
136
|
+
}, (import("@pramen/server").InferRow<import("@pramen/server").EntityFields> & Partial<{
|
|
137
|
+
[x: string]: import("@pramen/server").InferRow<import("@pramen/server").EntityFields> | import("@pramen/server").InferRow<import("@pramen/server").EntityFields>[] | null;
|
|
138
|
+
}>)[]>;
|
|
139
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
140
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
141
|
+
setUserRoles: import("@pramen/server").Handler<{
|
|
142
|
+
username: string;
|
|
143
|
+
roles: string[];
|
|
144
|
+
}, Record<string, unknown>>;
|
|
145
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
146
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
147
|
+
setUserActive: import("@pramen/server").Handler<{
|
|
148
|
+
username: string;
|
|
149
|
+
active: boolean;
|
|
150
|
+
}, Record<string, unknown>>;
|
|
151
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
152
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
153
|
+
deleteUser: import("@pramen/server").Handler<{
|
|
154
|
+
username: string;
|
|
155
|
+
}, {
|
|
156
|
+
ok: boolean;
|
|
157
|
+
}>;
|
|
158
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
159
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
160
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
161
|
+
changeEmail: import("@pramen/server").Handler<{
|
|
162
|
+
email: string;
|
|
163
|
+
}, Record<string, unknown>>;
|
|
164
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
165
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
166
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
167
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
168
|
+
* password and are rejected. */
|
|
169
|
+
changePassword: import("@pramen/server").Handler<{
|
|
170
|
+
currentPassword: string;
|
|
171
|
+
newPassword: string;
|
|
172
|
+
}, {
|
|
173
|
+
ok: boolean;
|
|
174
|
+
}>;
|
|
175
|
+
};
|
|
176
|
+
/** The default user-management handlers over `auth_users`. Equivalent to
|
|
177
|
+
* `createUserHandlers()`; spread alongside `authHandlers`. */
|
|
178
|
+
export declare const userHandlers: {
|
|
179
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
180
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
181
|
+
listUsers: import("@pramen/server").Handler<{
|
|
182
|
+
limit?: number;
|
|
183
|
+
offset?: number;
|
|
184
|
+
}, (import("@pramen/server").InferRow<import("@pramen/server").EntityFields> & Partial<{
|
|
185
|
+
[x: string]: import("@pramen/server").InferRow<import("@pramen/server").EntityFields> | import("@pramen/server").InferRow<import("@pramen/server").EntityFields>[] | null;
|
|
186
|
+
}>)[]>;
|
|
187
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
188
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
189
|
+
setUserRoles: import("@pramen/server").Handler<{
|
|
190
|
+
username: string;
|
|
191
|
+
roles: string[];
|
|
192
|
+
}, Record<string, unknown>>;
|
|
193
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
194
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
195
|
+
setUserActive: import("@pramen/server").Handler<{
|
|
196
|
+
username: string;
|
|
197
|
+
active: boolean;
|
|
198
|
+
}, Record<string, unknown>>;
|
|
199
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
200
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
201
|
+
deleteUser: import("@pramen/server").Handler<{
|
|
202
|
+
username: string;
|
|
203
|
+
}, {
|
|
204
|
+
ok: boolean;
|
|
205
|
+
}>;
|
|
206
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
207
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
208
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
209
|
+
changeEmail: import("@pramen/server").Handler<{
|
|
210
|
+
email: string;
|
|
211
|
+
}, Record<string, unknown>>;
|
|
212
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
213
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
214
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
215
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
216
|
+
* password and are rejected. */
|
|
217
|
+
changePassword: import("@pramen/server").Handler<{
|
|
218
|
+
currentPassword: string;
|
|
219
|
+
newPassword: string;
|
|
220
|
+
}, {
|
|
221
|
+
ok: boolean;
|
|
222
|
+
}>;
|
|
223
|
+
};
|
|
224
|
+
/** ACL policy fragments that turn on the user-management handlers. Spread `admin`
|
|
225
|
+
* into your admin role and `self` into your authenticated-user role:
|
|
226
|
+
*
|
|
227
|
+
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
228
|
+
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
229
|
+
*
|
|
230
|
+
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
231
|
+
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
232
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
233
|
+
*
|
|
234
|
+
* For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
|
|
235
|
+
* `prefix` (policy names must be unique across roles when you wire more than one
|
|
236
|
+
* instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
|
|
237
|
+
* (e.g. a `tenants` column managed by your own setUserTenants handler). */
|
|
238
|
+
export declare function authPolicies(opts?: {
|
|
239
|
+
table?: string;
|
|
240
|
+
identityPath?: string;
|
|
241
|
+
prefix?: string;
|
|
242
|
+
adminReadFields?: string[];
|
|
243
|
+
adminWriteFields?: string[];
|
|
244
|
+
selfReadFields?: string[];
|
|
245
|
+
selfWriteFields?: string[];
|
|
246
|
+
}): {
|
|
247
|
+
admin: Policy[];
|
|
248
|
+
self: Policy[];
|
|
249
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
// Passwordless magic-link login is also available via createMagicLinkAuth (spread
|
|
16
16
|
// magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
|
|
17
17
|
// the token lifecycle. See createMagicLinkAuth below.
|
|
18
|
-
import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
|
|
18
|
+
import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
|
|
19
19
|
// --- schema fragment: spread into your defineSchema so the table is migrated ---
|
|
20
20
|
export const authSchema = {
|
|
21
21
|
auth_users: Entity((t) => ({
|
|
22
|
-
username: t.textId(),
|
|
23
|
-
passwordHash: t.text(),
|
|
22
|
+
username: t.textId(), // stable identity / PK (= the JWT `sub`); not the email
|
|
23
|
+
passwordHash: hidden(t.text()), // never readable via the ORM; empty for passwordless users
|
|
24
24
|
roles: t.json(), // string[]
|
|
25
|
+
email: unique(t.text()), // mutable contact email (nullable, unique); the magic-link key
|
|
26
|
+
active: defaultTo(t.bool(), true), // deactivation flag — false blocks login (additive, backfills 1)
|
|
25
27
|
createdAt: t.int(),
|
|
26
28
|
})),
|
|
27
29
|
};
|
|
@@ -86,6 +88,12 @@ function secretOf(ctx) {
|
|
|
86
88
|
}
|
|
87
89
|
const DEFAULT_ROLES = ["user"];
|
|
88
90
|
const TOKEN_TTL_SECONDS = 3600;
|
|
91
|
+
/** Session-token lifetime: AUTH_SESSION_TTL_SECONDS from the env (a deployment can
|
|
92
|
+
* shorten it to tighten the deactivation/role-change window), else 1h. */
|
|
93
|
+
function sessionTtlOf(ctx) {
|
|
94
|
+
const v = Number(ctx.env.AUTH_SESSION_TTL_SECONDS);
|
|
95
|
+
return Number.isFinite(v) && v > 0 ? Math.trunc(v) : TOKEN_TTL_SECONDS;
|
|
96
|
+
}
|
|
89
97
|
function parseCreds(raw) {
|
|
90
98
|
const o = (raw ?? {});
|
|
91
99
|
if (typeof o.username !== "string" || o.username.length === 0)
|
|
@@ -103,17 +111,21 @@ export const authHandlers = {
|
|
|
103
111
|
throw new BadRequest("username is taken");
|
|
104
112
|
const roles = DEFAULT_ROLES;
|
|
105
113
|
await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", input.username, await hashPassword(input.password), JSON.stringify(roles), Date.now());
|
|
106
|
-
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds:
|
|
114
|
+
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
107
115
|
return { token, user: { username: input.username, roles } };
|
|
108
116
|
}, { input: parseCreds }),
|
|
109
117
|
login: mutation(async (ctx, input) => {
|
|
110
|
-
const rows = await ctx.db.exec("SELECT username, passwordHash, roles FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
118
|
+
const rows = await ctx.db.exec("SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
111
119
|
const u = rows[0];
|
|
112
120
|
if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
113
121
|
throw new Unauthorized("invalid username or password");
|
|
114
122
|
}
|
|
123
|
+
// Only after the password verifies (so this can't enumerate accounts): a
|
|
124
|
+
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
125
|
+
if (!isActive(u.active))
|
|
126
|
+
throw new Unauthorized("account is deactivated");
|
|
115
127
|
const roles = JSON.parse(String(u.roles));
|
|
116
|
-
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds:
|
|
128
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
117
129
|
return { token, user: { username: String(u.username), roles } };
|
|
118
130
|
}, { input: parseCreds }),
|
|
119
131
|
me: query((ctx) => ctx.identity),
|
|
@@ -194,19 +206,191 @@ export function createMagicLinkAuth(opts) {
|
|
|
194
206
|
// Single-use: consume before issuing the session.
|
|
195
207
|
await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
|
|
196
208
|
const email = String(link.email);
|
|
197
|
-
|
|
209
|
+
// Key on the USERNAME (the immutable identity = the JWT sub), NOT the mutable
|
|
210
|
+
// `email` column: a magic-link user's username IS their email address, so this
|
|
211
|
+
// both matches existing users and avoids resolving login by a mutable, unverified
|
|
212
|
+
// field (which would let a changeEmail squat another address — and would miss
|
|
213
|
+
// pre-`email`-column users on upgrade, colliding on the username PK).
|
|
214
|
+
const existing = await ctx.db.exec("SELECT roles, active FROM auth_users WHERE username = ? LIMIT 1", email);
|
|
198
215
|
let roles;
|
|
199
216
|
if (existing.length > 0) {
|
|
217
|
+
if (!isActive(existing[0].active))
|
|
218
|
+
throw new Unauthorized("account is deactivated");
|
|
200
219
|
roles = JSON.parse(String(existing[0].roles));
|
|
201
220
|
}
|
|
202
221
|
else {
|
|
203
222
|
roles = defaultRoles;
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
"INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", email, "", JSON.stringify(roles), Date.now());
|
|
223
|
+
// No email column set: it stays a pure contact attribute (set via changeEmail),
|
|
224
|
+
// so a new passwordless user can never collide with a password user's contact email.
|
|
225
|
+
await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", email, "", JSON.stringify(roles), Date.now());
|
|
207
226
|
}
|
|
208
227
|
const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
|
|
209
228
|
return { token, user: { username: email, roles } };
|
|
210
229
|
}, { input: parseLinkToken }),
|
|
211
230
|
};
|
|
212
231
|
}
|
|
232
|
+
// --- user management ---------------------------------------------------------
|
|
233
|
+
//
|
|
234
|
+
// Admin + self-service operations over `auth_users`, built the pramen way: ordinary
|
|
235
|
+
// handlers over `ctx.db` whose authorization is the ACL, not imperative `if (admin)`
|
|
236
|
+
// checks. They are inert until you grant access — spread `authPolicies()` into your
|
|
237
|
+
// roles (admin manages everyone; the authenticated user manages only itself). Because
|
|
238
|
+
// the admin read policy restricts `fields`, `passwordHash` is never projected back.
|
|
239
|
+
//
|
|
240
|
+
// Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
|
|
241
|
+
// take effect on the user's NEXT login — not instantly. That lag is the cost of the
|
|
242
|
+
// stateless, verify-only core (no session store, by design). Tune the revocation window
|
|
243
|
+
// with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
|
|
244
|
+
// app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
|
|
245
|
+
// deliberately left to the app rather than building a session store into the core.
|
|
246
|
+
/** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
|
|
247
|
+
function isActive(v) {
|
|
248
|
+
return v == null || Number(v) !== 0;
|
|
249
|
+
}
|
|
250
|
+
function requireUserId(ctx) {
|
|
251
|
+
const id = ctx.identity?.userId;
|
|
252
|
+
if (typeof id !== "string" || id.length === 0)
|
|
253
|
+
throw new Unauthorized("authentication required");
|
|
254
|
+
return id;
|
|
255
|
+
}
|
|
256
|
+
const usersDb = (ctx) => ctx.db;
|
|
257
|
+
// The users table must be a valid SQL identifier (it's interpolated into the raw exec
|
|
258
|
+
// strings below). It's app config, never request input, but guard it anyway.
|
|
259
|
+
function assertIdentifier(table) {
|
|
260
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
|
|
261
|
+
throw new Error(`@pramen/auth: invalid table name ${JSON.stringify(table)}`);
|
|
262
|
+
return table;
|
|
263
|
+
}
|
|
264
|
+
/** Build admin + self-service handlers over a users table (default `auth_users`).
|
|
265
|
+
* Pass `table` to operate over your OWN authSchema-shaped table — e.g. one with an
|
|
266
|
+
* extra `tenants` column — without renaming it: the handlers manage username/roles/
|
|
267
|
+
* email/active/delete and ignore any extra columns. Spread the result into your
|
|
268
|
+
* handler map and gate it with the matching `authPolicies({ table })`. The table must
|
|
269
|
+
* have a `username` primary key and (for changeEmail/changePassword) `email`/
|
|
270
|
+
* `passwordHash` columns. */
|
|
271
|
+
export function createUserHandlers(opts = {}) {
|
|
272
|
+
const table = assertIdentifier(opts.table ?? "auth_users");
|
|
273
|
+
return {
|
|
274
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
275
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
276
|
+
listUsers: query(async (ctx, input) => {
|
|
277
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
|
|
278
|
+
const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
|
|
279
|
+
return ctx.db.find({ from: table, orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
|
|
280
|
+
}),
|
|
281
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
282
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
283
|
+
setUserRoles: mutation(async (ctx, input) => {
|
|
284
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
285
|
+
throw new BadRequest("username is required");
|
|
286
|
+
if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
|
|
287
|
+
throw new BadRequest("roles must be a non-empty string[]");
|
|
288
|
+
}
|
|
289
|
+
const updated = await usersDb(ctx).update(table, input.username, { roles: input.roles });
|
|
290
|
+
if (!updated)
|
|
291
|
+
throw new BadRequest("user not found"); // (or out of the caller's update scope)
|
|
292
|
+
return updated;
|
|
293
|
+
}),
|
|
294
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
295
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
296
|
+
setUserActive: mutation(async (ctx, input) => {
|
|
297
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
298
|
+
throw new BadRequest("username is required");
|
|
299
|
+
if (typeof input?.active !== "boolean")
|
|
300
|
+
throw new BadRequest("active must be a boolean");
|
|
301
|
+
if (input.active === false && input.username === ctx.identity?.userId) {
|
|
302
|
+
throw new BadRequest("cannot deactivate your own account");
|
|
303
|
+
}
|
|
304
|
+
const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
|
|
305
|
+
if (!updated)
|
|
306
|
+
throw new BadRequest("user not found");
|
|
307
|
+
return updated;
|
|
308
|
+
}),
|
|
309
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
310
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
311
|
+
deleteUser: mutation(async (ctx, input) => {
|
|
312
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
313
|
+
throw new BadRequest("username is required");
|
|
314
|
+
if (input.username === ctx.identity?.userId)
|
|
315
|
+
throw new BadRequest("cannot delete your own account");
|
|
316
|
+
const deleted = await usersDb(ctx).delete(table, input.username);
|
|
317
|
+
if (!deleted)
|
|
318
|
+
throw new BadRequest("user not found");
|
|
319
|
+
return { ok: true };
|
|
320
|
+
}),
|
|
321
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
322
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
323
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
324
|
+
changeEmail: mutation(async (ctx, input) => {
|
|
325
|
+
const userId = requireUserId(ctx);
|
|
326
|
+
const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
|
|
327
|
+
const taken = await ctx.db.exec(`SELECT 1 FROM ${table} WHERE email = ? AND username != ? LIMIT 1`, email, userId);
|
|
328
|
+
if (taken.length > 0)
|
|
329
|
+
throw new BadRequest("email already in use");
|
|
330
|
+
const updated = await usersDb(ctx).update(table, userId, { email });
|
|
331
|
+
if (!updated)
|
|
332
|
+
throw new Unauthorized("authentication required");
|
|
333
|
+
return updated;
|
|
334
|
+
}),
|
|
335
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
336
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
337
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
338
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
339
|
+
* password and are rejected. */
|
|
340
|
+
changePassword: mutation(async (ctx, input) => {
|
|
341
|
+
const userId = requireUserId(ctx);
|
|
342
|
+
const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
|
|
343
|
+
const next = typeof input?.newPassword === "string" ? input.newPassword : "";
|
|
344
|
+
if (next.length < 8)
|
|
345
|
+
throw new BadRequest("newPassword must be at least 8 characters");
|
|
346
|
+
const rows = await ctx.db.exec(`SELECT passwordHash FROM ${table} WHERE username = ? LIMIT 1`, userId);
|
|
347
|
+
const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
|
|
348
|
+
if (stored === "" || !(await verifyPassword(current, stored))) {
|
|
349
|
+
throw new Unauthorized("current password is incorrect");
|
|
350
|
+
}
|
|
351
|
+
await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(next), userId);
|
|
352
|
+
return { ok: true };
|
|
353
|
+
}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** The default user-management handlers over `auth_users`. Equivalent to
|
|
357
|
+
* `createUserHandlers()`; spread alongside `authHandlers`. */
|
|
358
|
+
export const userHandlers = createUserHandlers();
|
|
359
|
+
// Fields a self-service caller may see of their own row (never passwordHash/roles).
|
|
360
|
+
const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
|
|
361
|
+
// Fields an admin may see of any user (never passwordHash).
|
|
362
|
+
const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
|
|
363
|
+
/** ACL policy fragments that turn on the user-management handlers. Spread `admin`
|
|
364
|
+
* into your admin role and `self` into your authenticated-user role:
|
|
365
|
+
*
|
|
366
|
+
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
367
|
+
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
368
|
+
*
|
|
369
|
+
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
370
|
+
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
371
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
372
|
+
*
|
|
373
|
+
* For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
|
|
374
|
+
* `prefix` (policy names must be unique across roles when you wire more than one
|
|
375
|
+
* instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
|
|
376
|
+
* (e.g. a `tenants` column managed by your own setUserTenants handler). */
|
|
377
|
+
export function authPolicies(opts = {}) {
|
|
378
|
+
const table = opts.table ?? "auth_users";
|
|
379
|
+
const idPath = opts.identityPath ?? "userId";
|
|
380
|
+
const p = opts.prefix ?? "auth";
|
|
381
|
+
const adminRead = opts.adminReadFields ?? ADMIN_READ_FIELDS;
|
|
382
|
+
const adminWrite = opts.adminWriteFields ?? ["roles", "email", "active"];
|
|
383
|
+
const selfRead = opts.selfReadFields ?? SELF_READ_FIELDS;
|
|
384
|
+
const selfWrite = opts.selfWriteFields ?? ["email"];
|
|
385
|
+
return {
|
|
386
|
+
admin: [
|
|
387
|
+
policy(`${p}:admin:read`, table, "read", { fields: adminRead }),
|
|
388
|
+
policy(`${p}:admin:update`, table, "update", { fields: adminWrite }),
|
|
389
|
+
policy(`${p}:admin:delete`, table, "delete", allow()),
|
|
390
|
+
],
|
|
391
|
+
self: [
|
|
392
|
+
policy(`${p}:self:read`, table, "read", { where: { username: $identity(idPath) }, fields: selfRead }),
|
|
393
|
+
policy(`${p}:self:update`, table, "update", { where: { username: $identity(idPath) }, fields: selfWrite }),
|
|
394
|
+
],
|
|
395
|
+
};
|
|
396
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pramen/server": "0.0.
|
|
37
|
+
"@pramen/server": "0.0.9"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,16 +16,18 @@
|
|
|
16
16
|
// magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
|
|
17
17
|
// the token lifecycle. See createMagicLinkAuth below.
|
|
18
18
|
|
|
19
|
-
import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
|
|
20
|
-
import type { HandlerContext } from "@pramen/server";
|
|
19
|
+
import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
|
|
20
|
+
import type { HandlerContext, Policy } from "@pramen/server";
|
|
21
21
|
|
|
22
22
|
// --- schema fragment: spread into your defineSchema so the table is migrated ---
|
|
23
23
|
|
|
24
24
|
export const authSchema = {
|
|
25
25
|
auth_users: Entity((t) => ({
|
|
26
|
-
username: t.textId(),
|
|
27
|
-
passwordHash: t.text(),
|
|
26
|
+
username: t.textId(), // stable identity / PK (= the JWT `sub`); not the email
|
|
27
|
+
passwordHash: hidden(t.text()), // never readable via the ORM; empty for passwordless users
|
|
28
28
|
roles: t.json(), // string[]
|
|
29
|
+
email: unique(t.text()), // mutable contact email (nullable, unique); the magic-link key
|
|
30
|
+
active: defaultTo(t.bool(), true), // deactivation flag — false blocks login (additive, backfills 1)
|
|
29
31
|
createdAt: t.int(),
|
|
30
32
|
})),
|
|
31
33
|
};
|
|
@@ -109,6 +111,13 @@ function secretOf(ctx: HandlerContext): string {
|
|
|
109
111
|
const DEFAULT_ROLES = ["user"];
|
|
110
112
|
const TOKEN_TTL_SECONDS = 3600;
|
|
111
113
|
|
|
114
|
+
/** Session-token lifetime: AUTH_SESSION_TTL_SECONDS from the env (a deployment can
|
|
115
|
+
* shorten it to tighten the deactivation/role-change window), else 1h. */
|
|
116
|
+
function sessionTtlOf(ctx: HandlerContext): number {
|
|
117
|
+
const v = Number(ctx.env.AUTH_SESSION_TTL_SECONDS);
|
|
118
|
+
return Number.isFinite(v) && v > 0 ? Math.trunc(v) : TOKEN_TTL_SECONDS;
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
function parseCreds(raw: unknown): { username: string; password: string } {
|
|
113
122
|
const o = (raw ?? {}) as Record<string, unknown>;
|
|
114
123
|
if (typeof o.username !== "string" || o.username.length === 0) throw new Error("username is required");
|
|
@@ -131,7 +140,7 @@ export const authHandlers = {
|
|
|
131
140
|
JSON.stringify(roles),
|
|
132
141
|
Date.now(),
|
|
133
142
|
);
|
|
134
|
-
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds:
|
|
143
|
+
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
135
144
|
return { token, user: { username: input.username, roles } };
|
|
136
145
|
},
|
|
137
146
|
{ input: parseCreds },
|
|
@@ -140,15 +149,18 @@ export const authHandlers = {
|
|
|
140
149
|
login: mutation(
|
|
141
150
|
async (ctx, input: { username: string; password: string }) => {
|
|
142
151
|
const rows = await ctx.db.exec(
|
|
143
|
-
"SELECT username, passwordHash, roles FROM auth_users WHERE username = ? LIMIT 1",
|
|
152
|
+
"SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1",
|
|
144
153
|
input.username,
|
|
145
154
|
);
|
|
146
155
|
const u = rows[0];
|
|
147
156
|
if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
148
157
|
throw new Unauthorized("invalid username or password");
|
|
149
158
|
}
|
|
159
|
+
// Only after the password verifies (so this can't enumerate accounts): a
|
|
160
|
+
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
161
|
+
if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
|
|
150
162
|
const roles = JSON.parse(String(u.roles)) as string[];
|
|
151
|
-
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds:
|
|
163
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
152
164
|
return { token, user: { username: String(u.username), roles } };
|
|
153
165
|
},
|
|
154
166
|
{ input: parseCreds },
|
|
@@ -270,14 +282,21 @@ export function createMagicLinkAuth(opts: MagicLinkOptions) {
|
|
|
270
282
|
await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
|
|
271
283
|
|
|
272
284
|
const email = String(link.email);
|
|
273
|
-
|
|
285
|
+
// Key on the USERNAME (the immutable identity = the JWT sub), NOT the mutable
|
|
286
|
+
// `email` column: a magic-link user's username IS their email address, so this
|
|
287
|
+
// both matches existing users and avoids resolving login by a mutable, unverified
|
|
288
|
+
// field (which would let a changeEmail squat another address — and would miss
|
|
289
|
+
// pre-`email`-column users on upgrade, colliding on the username PK).
|
|
290
|
+
const existing = await ctx.db.exec("SELECT roles, active FROM auth_users WHERE username = ? LIMIT 1", email);
|
|
274
291
|
let roles: string[];
|
|
275
292
|
if (existing.length > 0) {
|
|
293
|
+
if (!isActive(existing[0].active)) throw new Unauthorized("account is deactivated");
|
|
276
294
|
roles = JSON.parse(String(existing[0].roles)) as string[];
|
|
277
295
|
} else {
|
|
278
296
|
roles = defaultRoles;
|
|
297
|
+
// No email column set: it stays a pure contact attribute (set via changeEmail),
|
|
298
|
+
// so a new passwordless user can never collide with a password user's contact email.
|
|
279
299
|
await ctx.db.exec(
|
|
280
|
-
// Empty passwordHash can never verify → the user stays passwordless.
|
|
281
300
|
"INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
|
|
282
301
|
email,
|
|
283
302
|
"",
|
|
@@ -292,3 +311,184 @@ export function createMagicLinkAuth(opts: MagicLinkOptions) {
|
|
|
292
311
|
),
|
|
293
312
|
};
|
|
294
313
|
}
|
|
314
|
+
|
|
315
|
+
// --- user management ---------------------------------------------------------
|
|
316
|
+
//
|
|
317
|
+
// Admin + self-service operations over `auth_users`, built the pramen way: ordinary
|
|
318
|
+
// handlers over `ctx.db` whose authorization is the ACL, not imperative `if (admin)`
|
|
319
|
+
// checks. They are inert until you grant access — spread `authPolicies()` into your
|
|
320
|
+
// roles (admin manages everyone; the authenticated user manages only itself). Because
|
|
321
|
+
// the admin read policy restricts `fields`, `passwordHash` is never projected back.
|
|
322
|
+
//
|
|
323
|
+
// Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
|
|
324
|
+
// take effect on the user's NEXT login — not instantly. That lag is the cost of the
|
|
325
|
+
// stateless, verify-only core (no session store, by design). Tune the revocation window
|
|
326
|
+
// with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
|
|
327
|
+
// app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
|
|
328
|
+
// deliberately left to the app rather than building a session store into the core.
|
|
329
|
+
|
|
330
|
+
/** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
|
|
331
|
+
function isActive(v: unknown): boolean {
|
|
332
|
+
return v == null || Number(v) !== 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function requireUserId(ctx: HandlerContext): string {
|
|
336
|
+
const id = ctx.identity?.userId;
|
|
337
|
+
if (typeof id !== "string" || id.length === 0) throw new Unauthorized("authentication required");
|
|
338
|
+
return id;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// `ctx.db` is schema-typed against the *app's* composed schema, which this package
|
|
342
|
+
// can't import — so address the users table through a minimal structural view of the
|
|
343
|
+
// ACL'd Db. This is the same ctx.db at runtime: row-scope + field projection still apply.
|
|
344
|
+
interface UsersDb {
|
|
345
|
+
update(table: string, id: string, patch: Record<string, unknown>): Promise<Record<string, unknown> | undefined>;
|
|
346
|
+
delete(table: string, id: string): Promise<boolean>;
|
|
347
|
+
}
|
|
348
|
+
const usersDb = (ctx: HandlerContext): UsersDb => ctx.db as unknown as UsersDb;
|
|
349
|
+
|
|
350
|
+
// The users table must be a valid SQL identifier (it's interpolated into the raw exec
|
|
351
|
+
// strings below). It's app config, never request input, but guard it anyway.
|
|
352
|
+
function assertIdentifier(table: string): string {
|
|
353
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`@pramen/auth: invalid table name ${JSON.stringify(table)}`);
|
|
354
|
+
return table;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Build admin + self-service handlers over a users table (default `auth_users`).
|
|
358
|
+
* Pass `table` to operate over your OWN authSchema-shaped table — e.g. one with an
|
|
359
|
+
* extra `tenants` column — without renaming it: the handlers manage username/roles/
|
|
360
|
+
* email/active/delete and ignore any extra columns. Spread the result into your
|
|
361
|
+
* handler map and gate it with the matching `authPolicies({ table })`. The table must
|
|
362
|
+
* have a `username` primary key and (for changeEmail/changePassword) `email`/
|
|
363
|
+
* `passwordHash` columns. */
|
|
364
|
+
export function createUserHandlers(opts: { table?: string } = {}) {
|
|
365
|
+
const table = assertIdentifier(opts.table ?? "auth_users");
|
|
366
|
+
return {
|
|
367
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
368
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
369
|
+
listUsers: query(async (ctx, input: { limit?: number; offset?: number }) => {
|
|
370
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
|
|
371
|
+
const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
|
|
372
|
+
return ctx.db.find({ from: table, orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
|
|
373
|
+
}),
|
|
374
|
+
|
|
375
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
376
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
377
|
+
setUserRoles: mutation(async (ctx, input: { username: string; roles: string[] }) => {
|
|
378
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
379
|
+
if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
|
|
380
|
+
throw new BadRequest("roles must be a non-empty string[]");
|
|
381
|
+
}
|
|
382
|
+
const updated = await usersDb(ctx).update(table, input.username, { roles: input.roles });
|
|
383
|
+
if (!updated) throw new BadRequest("user not found"); // (or out of the caller's update scope)
|
|
384
|
+
return updated;
|
|
385
|
+
}),
|
|
386
|
+
|
|
387
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
388
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
389
|
+
setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
|
|
390
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
391
|
+
if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
|
|
392
|
+
if (input.active === false && input.username === ctx.identity?.userId) {
|
|
393
|
+
throw new BadRequest("cannot deactivate your own account");
|
|
394
|
+
}
|
|
395
|
+
const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
|
|
396
|
+
if (!updated) throw new BadRequest("user not found");
|
|
397
|
+
return updated;
|
|
398
|
+
}),
|
|
399
|
+
|
|
400
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
401
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
402
|
+
deleteUser: mutation(async (ctx, input: { username: string }) => {
|
|
403
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
404
|
+
if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
|
|
405
|
+
const deleted = await usersDb(ctx).delete(table, input.username);
|
|
406
|
+
if (!deleted) throw new BadRequest("user not found");
|
|
407
|
+
return { ok: true };
|
|
408
|
+
}),
|
|
409
|
+
|
|
410
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
411
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
412
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
413
|
+
changeEmail: mutation(async (ctx, input: { email: string }) => {
|
|
414
|
+
const userId = requireUserId(ctx);
|
|
415
|
+
const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
|
|
416
|
+
const taken = await ctx.db.exec(`SELECT 1 FROM ${table} WHERE email = ? AND username != ? LIMIT 1`, email, userId);
|
|
417
|
+
if (taken.length > 0) throw new BadRequest("email already in use");
|
|
418
|
+
const updated = await usersDb(ctx).update(table, userId, { email });
|
|
419
|
+
if (!updated) throw new Unauthorized("authentication required");
|
|
420
|
+
return updated;
|
|
421
|
+
}),
|
|
422
|
+
|
|
423
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
424
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
425
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
426
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
427
|
+
* password and are rejected. */
|
|
428
|
+
changePassword: mutation(async (ctx, input: { currentPassword: string; newPassword: string }) => {
|
|
429
|
+
const userId = requireUserId(ctx);
|
|
430
|
+
const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
|
|
431
|
+
const next = typeof input?.newPassword === "string" ? input.newPassword : "";
|
|
432
|
+
if (next.length < 8) throw new BadRequest("newPassword must be at least 8 characters");
|
|
433
|
+
const rows = await ctx.db.exec(`SELECT passwordHash FROM ${table} WHERE username = ? LIMIT 1`, userId);
|
|
434
|
+
const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
|
|
435
|
+
if (stored === "" || !(await verifyPassword(current, stored))) {
|
|
436
|
+
throw new Unauthorized("current password is incorrect");
|
|
437
|
+
}
|
|
438
|
+
await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(next), userId);
|
|
439
|
+
return { ok: true };
|
|
440
|
+
}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The default user-management handlers over `auth_users`. Equivalent to
|
|
445
|
+
* `createUserHandlers()`; spread alongside `authHandlers`. */
|
|
446
|
+
export const userHandlers = createUserHandlers();
|
|
447
|
+
|
|
448
|
+
// Fields a self-service caller may see of their own row (never passwordHash/roles).
|
|
449
|
+
const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
|
|
450
|
+
// Fields an admin may see of any user (never passwordHash).
|
|
451
|
+
const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
|
|
452
|
+
|
|
453
|
+
/** ACL policy fragments that turn on the user-management handlers. Spread `admin`
|
|
454
|
+
* into your admin role and `self` into your authenticated-user role:
|
|
455
|
+
*
|
|
456
|
+
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
457
|
+
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
458
|
+
*
|
|
459
|
+
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
460
|
+
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
461
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
462
|
+
*
|
|
463
|
+
* For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
|
|
464
|
+
* `prefix` (policy names must be unique across roles when you wire more than one
|
|
465
|
+
* instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
|
|
466
|
+
* (e.g. a `tenants` column managed by your own setUserTenants handler). */
|
|
467
|
+
export function authPolicies(opts: {
|
|
468
|
+
table?: string;
|
|
469
|
+
identityPath?: string;
|
|
470
|
+
prefix?: string;
|
|
471
|
+
adminReadFields?: string[];
|
|
472
|
+
adminWriteFields?: string[];
|
|
473
|
+
selfReadFields?: string[];
|
|
474
|
+
selfWriteFields?: string[];
|
|
475
|
+
} = {}): { admin: Policy[]; self: Policy[] } {
|
|
476
|
+
const table = opts.table ?? "auth_users";
|
|
477
|
+
const idPath = opts.identityPath ?? "userId";
|
|
478
|
+
const p = opts.prefix ?? "auth";
|
|
479
|
+
const adminRead = opts.adminReadFields ?? ADMIN_READ_FIELDS;
|
|
480
|
+
const adminWrite = opts.adminWriteFields ?? ["roles", "email", "active"];
|
|
481
|
+
const selfRead = opts.selfReadFields ?? SELF_READ_FIELDS;
|
|
482
|
+
const selfWrite = opts.selfWriteFields ?? ["email"];
|
|
483
|
+
return {
|
|
484
|
+
admin: [
|
|
485
|
+
policy(`${p}:admin:read`, table, "read", { fields: adminRead }),
|
|
486
|
+
policy(`${p}:admin:update`, table, "update", { fields: adminWrite }),
|
|
487
|
+
policy(`${p}:admin:delete`, table, "delete", allow()),
|
|
488
|
+
],
|
|
489
|
+
self: [
|
|
490
|
+
policy(`${p}:self:read`, table, "read", { where: { username: $identity(idPath) }, fields: selfRead }),
|
|
491
|
+
policy(`${p}:self:update`, table, "update", { where: { username: $identity(idPath) }, fields: selfWrite }),
|
|
492
|
+
],
|
|
493
|
+
};
|
|
494
|
+
}
|