@awsmbiz/inbis-access-control 0.0.0-stage → 0.1.0
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/README.md +169 -2
- package/dist/access-control-eE6LZrpn.d.ts +197 -0
- package/dist/hono.d.ts +90 -0
- package/dist/hono.js +172 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +438 -0
- package/dist/index.js.map +1 -0
- package/dist/role-fields-ClqODoYn.js +72 -0
- package/dist/role-fields-ClqODoYn.js.map +1 -0
- package/migrations/0001_access_control_initial.sql +52 -0
- package/migrations/migrations.lock.json +3 -0
- package/package.json +66 -3
package/README.md
CHANGED
|
@@ -1,3 +1,170 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @awsmbiz/inbis-access-control
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Business permissions for Inbis applications: a typed permission catalogue and system roles
|
|
4
|
+
declared in code, roles and assignments in D1, `authorize()` and a `requirePermission()` Hono
|
|
5
|
+
middleware, and package-owned D1 migrations (ADR-4).
|
|
6
|
+
|
|
7
|
+
It is separate from the platform administrator role (`user.role = "admin"` in `@awsmbiz/inbis-auth`,
|
|
8
|
+
plan Q12): a platform admin gets **no** business permission implicitly (Q41).
|
|
9
|
+
|
|
10
|
+
## Public API
|
|
11
|
+
|
|
12
|
+
| Entry | Contents |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `@awsmbiz/inbis-access-control` | `defineAccessControl`, `createAccessControl`, `ACCESS_CONTROL_AUDIT_ACTIONS`, `ACCESS_CONTROL_MIGRATIONS_TABLE`; types `AccessControl`, `AccessControlDefinition`, `AccessControlInput`, `AccessControlOptions`, `AuthorizeInput`, `RoleAssignmentInput`, `RoleDeclaration`, `PermissionDefinition`, `SystemRoleDefinition`, `SeedResult`, `Role`, `RoleFields`, `CreateRoleInput`, `UpdateRoleInput`, `DeleteRoleInput`, `UserRole` |
|
|
15
|
+
| `@awsmbiz/inbis-access-control/hono` | `honoAccessControl` (→ `requirePermission`, `accessControlOf`), `mountRoleAdmin`; types `AccessControlResolver`, `AccessControlVariables`, `HonoAccessControl`, `HonoAccessControlOptions`, `MountRoleAdminOptions`, `RoleResponse`, `UserRoleResponse` |
|
|
16
|
+
| `migrations/` | Numbered, frozen SQL plus `migrations.lock.json`, shipped in the tarball |
|
|
17
|
+
|
|
18
|
+
Optional peer dependency: `hono` ^4.13.14 (for `/hono`).
|
|
19
|
+
|
|
20
|
+
## Setting it up
|
|
21
|
+
|
|
22
|
+
1. Declare an access-control binding to the app's database, after auth's (`user_role`
|
|
23
|
+
references auth's `user` table) and before audit's, with the same `database_id`:
|
|
24
|
+
|
|
25
|
+
```jsonc
|
|
26
|
+
"d1_databases": [
|
|
27
|
+
{ "binding": "AUTH_DB", "database_name": "myapp-dev", "database_id": "<id>",
|
|
28
|
+
"migrations_dir": "node_modules/@awsmbiz/inbis-auth/migrations", "migrations_table": "_inbis_auth_migrations" },
|
|
29
|
+
{ "binding": "ACCESS_CONTROL_DB", "database_name": "myapp-dev", "database_id": "<id>",
|
|
30
|
+
"migrations_dir": "node_modules/@awsmbiz/inbis-access-control/migrations", "migrations_table": "_inbis_access_control_migrations" },
|
|
31
|
+
{ "binding": "AUDIT_DB", ... },
|
|
32
|
+
{ "binding": "DB", ... }
|
|
33
|
+
]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
2. Declare the catalogue once, in code:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { defineAccessControl } from "@awsmbiz/inbis-access-control";
|
|
40
|
+
|
|
41
|
+
export const access = defineAccessControl({
|
|
42
|
+
permissions: { "notes.read": "Read notes", "notes.write": "Create and edit notes" },
|
|
43
|
+
roles: {
|
|
44
|
+
reader: { name: "Reader", permissions: ["notes.read"] },
|
|
45
|
+
editor: { name: "Editor", permissions: ["notes.write"], inherits: ["reader"] },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Permission names are dotted lowercase (`notes.read`) and typed: a role granting an
|
|
51
|
+
undeclared permission, inheriting an undeclared role, or a call to
|
|
52
|
+
`authorize({ permission: "notes.reed" })` is a compile error. At runtime
|
|
53
|
+
`defineAccessControl` also throws on malformed names, duplicate role names and inheritance
|
|
54
|
+
cycles.
|
|
55
|
+
|
|
56
|
+
3. Protect routes (build the helpers once per isolate, the instance per request):
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { createAccessControl } from "@awsmbiz/inbis-access-control";
|
|
60
|
+
import { honoAccessControl } from "@awsmbiz/inbis-access-control/hono";
|
|
61
|
+
|
|
62
|
+
const { requirePermission, accessControlOf } = honoAccessControl((c) =>
|
|
63
|
+
createAccessControl(requireBinding(c.env, "ACCESS_CONTROL_DB"), access, {
|
|
64
|
+
audit, // e.g. @awsmbiz/inbis-audit's sink
|
|
65
|
+
requestId: c.var.requestId,
|
|
66
|
+
logger: c.var.logger,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
app.get("/v1/notes", requireAuth(), requirePermission("notes.read"), handler);
|
|
71
|
+
// Or in code: await (await accessControlOf(c)).authorize({ userId, permission: "notes.write" })
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`requirePermission` reads the user from `c.var.user` (`@awsmbiz/inbis-auth/hono`'s
|
|
75
|
+
`sessionMiddleware`; pass `{ userId: (c) => ... }` otherwise). Signed out: `401
|
|
76
|
+
UNAUTHENTICATED`; not allowed: `403 FORBIDDEN` with `details.permission`. Both are
|
|
77
|
+
`AppError`s that `@awsmbiz/inbis-api` renders as the standard error envelope. Put it after
|
|
78
|
+
`requireAuth()`, which applies the session policy.
|
|
79
|
+
|
|
80
|
+
4. Mount the role admin API behind the platform admin policy (changes also need a recent
|
|
81
|
+
sign-in):
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { requireAdmin, requireRecentAdminSignIn } from "@awsmbiz/inbis-auth/hono";
|
|
85
|
+
import { mountRoleAdmin } from "@awsmbiz/inbis-access-control/hono";
|
|
86
|
+
|
|
87
|
+
mountRoleAdmin(app, { accessControl: accessControlOf, guard: requireAdmin(), changeGuard: requireRecentAdminSignIn() });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
5. Register the audit actions with the app's trail:
|
|
91
|
+
`createAuditActions({ platform: [AUTH_AUDIT_ACTIONS, ACCESS_CONTROL_AUDIT_ACTIONS] })`.
|
|
92
|
+
|
|
93
|
+
## Behaviour
|
|
94
|
+
|
|
95
|
+
- **Seeding.** `seed()` makes D1 hold exactly the declared catalogue in one batch: upserts
|
|
96
|
+
permissions and system roles, replaces system roles' grants and inheritance, and deletes
|
|
97
|
+
permissions and system roles no longer declared (with their grants and assignments). Custom
|
|
98
|
+
roles stay (losing grants of removed permissions). A fingerprint row makes it a single read
|
|
99
|
+
when nothing changed. `honoAccessControl` runs it on the isolate's first use. A custom role
|
|
100
|
+
with the same name as a new system role makes seeding fail (role names are unique).
|
|
101
|
+
- **`authorize({ userId, permission, resource? })`** reads D1 on every request: the user's
|
|
102
|
+
permissions through their roles and the roles those **explicitly** inherit (transitively),
|
|
103
|
+
memoised for the life of one `AccessControl` instance (one request), never cached in KV
|
|
104
|
+
(Q42). Removing a role takes effect on the next request. An undeclared permission denies,
|
|
105
|
+
even if a stale row grants it. `resource` is reserved for app-defined scoping and ignored
|
|
106
|
+
today.
|
|
107
|
+
- **Assignments.** `assignRole` / `unassignRole({ userId, roleId, actorId })` are idempotent
|
|
108
|
+
and record `access.role.assigned` / `access.role.unassigned` (subject `user:<id>`, metadata
|
|
109
|
+
`roleId`) through the injected `AuditSink` when they change something; a failing sink is
|
|
110
|
+
logged (`access.audit.record_failed`), never the caller's failure. Unknown role or user:
|
|
111
|
+
`404 ROLE_NOT_FOUND` / `404 USER_NOT_FOUND`. `rolesOf(userId)` lists direct assignments.
|
|
112
|
+
- **Custom roles.** Admins create roles from the declared permissions (`createRole`,
|
|
113
|
+
`updateRole`, `deleteRole`); system roles are read-only (`409 SYSTEM_ROLE_IMMUTABLE`). Names
|
|
114
|
+
are 1–64 characters, unique case-insensitively (`409 ROLE_NAME_TAKEN`); undeclared
|
|
115
|
+
permissions are `422 UNKNOWN_PERMISSION`, inheriting a missing role `422 UNKNOWN_ROLE`, and an
|
|
116
|
+
edge that would close a loop `422 ROLE_INHERITANCE_CYCLE` (`details.cycle`). Deleting a role
|
|
117
|
+
deletes its assignments, but is refused while another role inherits it
|
|
118
|
+
(`409 ROLE_INHERITED`, `details.roleIds`). Audited (subject `role:<id>`) as
|
|
119
|
+
`access.role.created` (the fields), `access.role.updated` (`changes`: `{ from, to }` for
|
|
120
|
+
`name`/`description`, `{ added, removed }` for `permissions`/`inherits`; an update that
|
|
121
|
+
changes nothing writes and audits nothing) and `access.role.deleted` (the fields and
|
|
122
|
+
`unassignedUserIds`). Role data holds no secrets. Concurrent edits are last-write-wins.
|
|
123
|
+
|
|
124
|
+
## Role admin API
|
|
125
|
+
|
|
126
|
+
`mountRoleAdmin(app, { accessControl, guard, changeGuard, actorIdOf?, basePath? })` mounts, under
|
|
127
|
+
`/api/admin` (all `Cache-Control: no-store`, errors in the `@awsmbiz/inbis-api` envelope):
|
|
128
|
+
|
|
129
|
+
| Route | Body | Response |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `GET /permissions` | | `{ items: [{ name, description }] }` |
|
|
132
|
+
| `GET /roles` | | `{ items: Role[] }`, system roles first, then by name |
|
|
133
|
+
| `GET /roles/:roleId` | | `Role` |
|
|
134
|
+
| `POST /roles` | `{ name, description?, permissions, inherits? }` | `201 Role` |
|
|
135
|
+
| `PATCH /roles/:roleId` | any of those fields | `Role` |
|
|
136
|
+
| `DELETE /roles/:roleId` | | `204` |
|
|
137
|
+
| `GET /users/:userId/roles` | | `{ items: UserRole[] }` |
|
|
138
|
+
| `PUT /users/:userId/roles/:roleId` | | `{ items: UserRole[] }` (idempotent) |
|
|
139
|
+
| `DELETE /users/:userId/roles/:roleId` | | `{ items: UserRole[] }` (idempotent) |
|
|
140
|
+
|
|
141
|
+
`Role` = `{ id, name, description, system, permissions, inherits, userCount, createdAt, updatedAt }`
|
|
142
|
+
and `UserRole` = `{ id, name, system, assignedAt, assignedBy }` (timestamps ISO 8601). Custom role
|
|
143
|
+
IDs are random UUIDs. Reads pass `guard` (the platform admin policy, `requireAdmin()`);
|
|
144
|
+
changes then pass `changeGuard` (`requireRecentAdminSignIn()`). Error codes: `422
|
|
145
|
+
VALIDATION_FAILED` (`details.issues[{ path, message }]`), `422 UNKNOWN_PERMISSION`, `422
|
|
146
|
+
UNKNOWN_ROLE`, `422 ROLE_INHERITANCE_CYCLE`, `409 ROLE_NAME_TAKEN`, `409 SYSTEM_ROLE_IMMUTABLE`,
|
|
147
|
+
`409 ROLE_INHERITED`, `404 ROLE_NOT_FOUND`, `404 USER_NOT_FOUND`, plus the guards' (`401
|
|
148
|
+
UNAUTHENTICATED`, `403 FORBIDDEN`, `403 TWO_FACTOR_REQUIRED`, `403 STEP_UP_REQUIRED`, `403
|
|
149
|
+
SESSION_NOT_FRESH`).
|
|
150
|
+
- **Account deletion.** `user_role.user_id` references `user(id)` `ON DELETE CASCADE`, so every
|
|
151
|
+
deletion path (`/delete-user`, the scheduled purge, `/admin/remove-user`) removes the user's
|
|
152
|
+
role assignments; no deletion hook is needed. The foreign key is deliberate, and it means
|
|
153
|
+
**access-control's migrations run after auth's** (ADR-4 platform order): an app installs
|
|
154
|
+
`@awsmbiz/inbis-auth` first, and a database without auth's `user` table cannot take them.
|
|
155
|
+
- **Actor.** Each change records the signed-in admin as its actor (`actorIdOf`, default
|
|
156
|
+
`c.var.user?.id`); a change that reaches the routes without one is refused with `401
|
|
157
|
+
UNAUTHENTICATED`, never audited without an actor.
|
|
158
|
+
|
|
159
|
+
## Tables
|
|
160
|
+
|
|
161
|
+
`permission(name, description)`, `role(id, name unique, description, system, created_at,
|
|
162
|
+
updated_at)`, `role_permission(role_id, permission)`, `role_inheritance(role_id,
|
|
163
|
+
inherits_role_id)`, `user_role(user_id, role_id, assigned_at, assigned_by)` and
|
|
164
|
+
`access_control_seed` (the catalogue fingerprint). System role IDs are the keys declared in
|
|
165
|
+
code; timestamps are epoch milliseconds. Apps use the API, never the tables.
|
|
166
|
+
|
|
167
|
+
## Changing the schema (maintainers)
|
|
168
|
+
|
|
169
|
+
Migrations are append-only: add `migrations/000N_*.sql`, then
|
|
170
|
+
`pnpm exec inbis-db lock migrations`. `pnpm check:migrations` fails if a released file changed.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { AuditSink, Clock, Logger } from "@awsmbiz/inbis-core";
|
|
2
|
+
/**
|
|
3
|
+
* The permission catalogue and system roles, declared in the app's code
|
|
4
|
+
* (plan Q40). Permission names are typed: a typo is a compile error.
|
|
5
|
+
*/
|
|
6
|
+
/** A system role as the app declares it. */
|
|
7
|
+
interface RoleDeclaration<P extends string, R extends string> {
|
|
8
|
+
/** Display name, unique among all roles (custom ones included). */
|
|
9
|
+
name: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Permissions the role grants itself. */
|
|
12
|
+
permissions: readonly P[];
|
|
13
|
+
/**
|
|
14
|
+
* Roles whose permissions this role also grants (transitively). Explicit
|
|
15
|
+
* inheritance is the only inheritance: nothing is implied by names or order.
|
|
16
|
+
*/
|
|
17
|
+
inherits?: readonly R[];
|
|
18
|
+
}
|
|
19
|
+
interface AccessControlInput<P extends string, R extends string> {
|
|
20
|
+
/** Every permission the app checks, with a description for the admin UI: `{ "notes.read": "Read notes" }`. */
|
|
21
|
+
permissions: Readonly<Record<P, string>>;
|
|
22
|
+
/** System roles by ID: seeded into D1 and immutable through the API. */
|
|
23
|
+
roles: Readonly<Record<R, RoleDeclaration<NoInfer<P>, NoInfer<R>>>>;
|
|
24
|
+
}
|
|
25
|
+
interface PermissionDefinition<P extends string = string> {
|
|
26
|
+
name: P;
|
|
27
|
+
description: string;
|
|
28
|
+
}
|
|
29
|
+
interface SystemRoleDefinition<P extends string = string, R extends string = string> {
|
|
30
|
+
id: R;
|
|
31
|
+
name: string;
|
|
32
|
+
description: string | null;
|
|
33
|
+
permissions: readonly P[];
|
|
34
|
+
inherits: readonly R[];
|
|
35
|
+
}
|
|
36
|
+
/** A validated catalogue, from {@link defineAccessControl}. */
|
|
37
|
+
interface AccessControlDefinition<P extends string = string, R extends string = string> {
|
|
38
|
+
/** Sorted by name. */
|
|
39
|
+
readonly permissions: readonly PermissionDefinition<P>[];
|
|
40
|
+
/** Sorted by ID. */
|
|
41
|
+
readonly roles: readonly SystemRoleDefinition<P, R>[];
|
|
42
|
+
/** True for a declared permission name. */
|
|
43
|
+
hasPermission(name: string): name is P;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Declares the app's permissions and system roles. Throws on a malformed name,
|
|
47
|
+
* an undeclared permission or role, a duplicate role name or an inheritance
|
|
48
|
+
* cycle: all programming errors, found at startup.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* export const access = defineAccessControl({
|
|
52
|
+
* permissions: { "notes.read": "Read notes", "notes.write": "Write notes" },
|
|
53
|
+
* roles: {
|
|
54
|
+
* reader: { name: "Reader", permissions: ["notes.read"] },
|
|
55
|
+
* editor: { name: "Editor", permissions: ["notes.write"], inherits: ["reader"] },
|
|
56
|
+
* },
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
declare function defineAccessControl<const P extends string, const R extends string>(input: AccessControlInput<P, R>): AccessControlDefinition<P, R>;
|
|
61
|
+
/** A custom role's editable fields. */
|
|
62
|
+
interface RoleFields {
|
|
63
|
+
/** Display name, 1–64 characters, unique among all roles (case-insensitive). */
|
|
64
|
+
name: string;
|
|
65
|
+
/** Up to 500 characters; `null` or `""` for none. */
|
|
66
|
+
description?: string | null;
|
|
67
|
+
/** Declared permission names the role grants itself. */
|
|
68
|
+
permissions: readonly string[];
|
|
69
|
+
/** IDs of roles (system or custom) whose permissions this role also grants. */
|
|
70
|
+
inherits?: readonly string[];
|
|
71
|
+
}
|
|
72
|
+
/** A role as the admin API shows it. */
|
|
73
|
+
interface Role {
|
|
74
|
+
id: string;
|
|
75
|
+
name: string;
|
|
76
|
+
description: string | null;
|
|
77
|
+
/** Declared in code: read-only through the API. */
|
|
78
|
+
system: boolean;
|
|
79
|
+
/** Permissions the role grants itself (declared ones only), sorted. */
|
|
80
|
+
permissions: string[];
|
|
81
|
+
/** IDs of the roles it inherits directly, sorted. */
|
|
82
|
+
inherits: string[];
|
|
83
|
+
/** Users the role is assigned to directly. */
|
|
84
|
+
userCount: number;
|
|
85
|
+
createdAt: Date;
|
|
86
|
+
updatedAt: Date;
|
|
87
|
+
}
|
|
88
|
+
/** A role assigned to a user directly. */
|
|
89
|
+
interface UserRole {
|
|
90
|
+
/** The role's ID. */
|
|
91
|
+
id: string;
|
|
92
|
+
name: string;
|
|
93
|
+
system: boolean;
|
|
94
|
+
assignedAt: Date;
|
|
95
|
+
/** The admin who assigned it; `null` for the system. */
|
|
96
|
+
assignedBy: string | null;
|
|
97
|
+
}
|
|
98
|
+
interface CreateRoleInput extends RoleFields {
|
|
99
|
+
actorId: string | null;
|
|
100
|
+
}
|
|
101
|
+
interface UpdateRoleInput {
|
|
102
|
+
roleId: string;
|
|
103
|
+
/** The fields to change; others keep their value. */
|
|
104
|
+
changes: Partial<RoleFields>;
|
|
105
|
+
actorId: string | null;
|
|
106
|
+
}
|
|
107
|
+
interface DeleteRoleInput {
|
|
108
|
+
roleId: string;
|
|
109
|
+
actorId: string | null;
|
|
110
|
+
}
|
|
111
|
+
/** What {@link seedCatalogue} did. */
|
|
112
|
+
interface SeedResult {
|
|
113
|
+
/** `false` when the database already held this exact catalogue (nothing written). */
|
|
114
|
+
changed: boolean;
|
|
115
|
+
}
|
|
116
|
+
interface AuthorizeInput<P extends string = string> {
|
|
117
|
+
userId: string;
|
|
118
|
+
permission: P;
|
|
119
|
+
/**
|
|
120
|
+
* The record the action applies to, e.g. `note:<id>`. Reserved: ignored
|
|
121
|
+
* today, so apps can add resource scoping later without a breaking change.
|
|
122
|
+
*/
|
|
123
|
+
resource?: string;
|
|
124
|
+
}
|
|
125
|
+
interface RoleAssignmentInput {
|
|
126
|
+
userId: string;
|
|
127
|
+
roleId: string;
|
|
128
|
+
/** Who made the change (audited); `null` for the system. */
|
|
129
|
+
actorId: string | null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Access control for one request (create one per request: answers are
|
|
133
|
+
* memoised for its life only, and forgotten when it changes roles or assignments).
|
|
134
|
+
*/
|
|
135
|
+
interface AccessControl<P extends string = string> {
|
|
136
|
+
/**
|
|
137
|
+
* `true` when one of the user's roles, or a role they explicitly inherit,
|
|
138
|
+
* grants the permission. Reads D1 (once per user per instance, never a
|
|
139
|
+
* cache), so revocation applies on the next request. Unknown permissions
|
|
140
|
+
* deny. A platform admin (`role` `admin`) gets nothing implicitly.
|
|
141
|
+
*/
|
|
142
|
+
authorize(input: AuthorizeInput<P>): Promise<boolean>;
|
|
143
|
+
/** IDs of the roles assigned to the user directly (not inherited), sorted. */
|
|
144
|
+
rolesOf(userId: string): Promise<string[]>;
|
|
145
|
+
/**
|
|
146
|
+
* Assigns a role (idempotent). Audited as `access.role.assigned` when it
|
|
147
|
+
* changes anything. `404 ROLE_NOT_FOUND` / `404 USER_NOT_FOUND`.
|
|
148
|
+
*/
|
|
149
|
+
assignRole(input: RoleAssignmentInput): Promise<void>;
|
|
150
|
+
/** Removes a role assignment (idempotent). Audited as `access.role.unassigned` when it changes anything. */
|
|
151
|
+
unassignRole(input: RoleAssignmentInput): Promise<void>;
|
|
152
|
+
/** Seeds the catalogue and system roles into D1 (see `seedCatalogue`); run once per isolate before use. */
|
|
153
|
+
seed(): Promise<SeedResult>;
|
|
154
|
+
/** The declared permission catalogue, sorted by name. */
|
|
155
|
+
listPermissions(): PermissionDefinition<P>[];
|
|
156
|
+
/** Every role: system roles first, then by name. */
|
|
157
|
+
listRoles(): Promise<Role[]>;
|
|
158
|
+
/** `404 ROLE_NOT_FOUND` for an unknown ID. */
|
|
159
|
+
getRole(roleId: string): Promise<Role>;
|
|
160
|
+
/**
|
|
161
|
+
* Creates a custom role. Audited as `access.role.created`. `422 VALIDATION_FAILED`
|
|
162
|
+
* (name, description), `422 UNKNOWN_PERMISSION` (`details.permissions`),
|
|
163
|
+
* `422 UNKNOWN_ROLE` (`details.roleIds`), `409 ROLE_NAME_TAKEN`.
|
|
164
|
+
*/
|
|
165
|
+
createRole(input: CreateRoleInput): Promise<Role>;
|
|
166
|
+
/**
|
|
167
|
+
* Changes a custom role's fields. Audited as `access.role.updated` with a diff
|
|
168
|
+
* (nothing is written or audited when nothing changes). As {@link createRole},
|
|
169
|
+
* plus `404 ROLE_NOT_FOUND`, `409 SYSTEM_ROLE_IMMUTABLE` and
|
|
170
|
+
* `422 ROLE_INHERITANCE_CYCLE` (`details.cycle`, role IDs from this role back to it).
|
|
171
|
+
*/
|
|
172
|
+
updateRole(input: UpdateRoleInput): Promise<Role>;
|
|
173
|
+
/**
|
|
174
|
+
* Deletes a custom role and its assignments. Audited as `access.role.deleted`.
|
|
175
|
+
* `404 ROLE_NOT_FOUND`, `409 SYSTEM_ROLE_IMMUTABLE`, `409 ROLE_INHERITED`
|
|
176
|
+
* (`details.roleIds`: roles that inherit it).
|
|
177
|
+
*/
|
|
178
|
+
deleteRole(input: DeleteRoleInput): Promise<void>;
|
|
179
|
+
/** The user's directly assigned roles. `404 USER_NOT_FOUND`. */
|
|
180
|
+
userRoles(userId: string): Promise<UserRole[]>;
|
|
181
|
+
}
|
|
182
|
+
interface AccessControlOptions {
|
|
183
|
+
/** Receives role-assignment events (e.g. `@awsmbiz/inbis-audit`'s sink). Without it, nothing is audited. */
|
|
184
|
+
audit?: AuditSink;
|
|
185
|
+
/** Stamped on audit events. */
|
|
186
|
+
requestId?: string;
|
|
187
|
+
/** Receives `access.audit.record_failed`. Defaults to a console JSON logger. */
|
|
188
|
+
logger?: Logger;
|
|
189
|
+
clock?: Clock;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Access control over the database holding the access-control tables (the
|
|
193
|
+
* app's access-control binding), for one request.
|
|
194
|
+
*/
|
|
195
|
+
declare function createAccessControl<P extends string>(db: D1Database, definition: AccessControlDefinition<P>, options?: AccessControlOptions): AccessControl<P>;
|
|
196
|
+
export { SystemRoleDefinition as _, createAccessControl as a, DeleteRoleInput as c, UserRole as d, RoleFields as f, RoleDeclaration as g, PermissionDefinition as h, RoleAssignmentInput as i, Role as l, AccessControlInput as m, AccessControlOptions as n, SeedResult as o, AccessControlDefinition as p, AuthorizeInput as r, CreateRoleInput as s, AccessControl as t, UpdateRoleInput as u, defineAccessControl as v };
|
|
197
|
+
//# sourceMappingURL=access-control-eE6LZrpn.d.ts.map
|
package/dist/hono.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { d as UserRole, l as Role, t as AccessControl } from "./access-control-eE6LZrpn.js";
|
|
2
|
+
import { Context, Env, Hono, MiddlewareHandler } from "hono";
|
|
3
|
+
/** A role as the admin API returns it: {@link Role} with ISO 8601 timestamps. */
|
|
4
|
+
interface RoleResponse extends Omit<Role, "createdAt" | "updatedAt"> {
|
|
5
|
+
createdAt: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
}
|
|
8
|
+
/** A user's role as the admin API returns it: {@link UserRole} with an ISO 8601 `assignedAt`. */
|
|
9
|
+
interface UserRoleResponse extends Omit<UserRole, "assignedAt"> {
|
|
10
|
+
assignedAt: string;
|
|
11
|
+
}
|
|
12
|
+
interface MountRoleAdminOptions<E extends Env = Env> {
|
|
13
|
+
/** The request's access control, e.g. `accessControlOf` from {@link honoAccessControl} (seeds the catalogue). */
|
|
14
|
+
accessControl: (c: Context<E>) => Promise<AccessControl>;
|
|
15
|
+
/**
|
|
16
|
+
* Admits the caller to every route, or throws: pass the app's admin policy
|
|
17
|
+
* (`requireAdmin()` from `@awsmbiz/inbis-auth/hono`). Any env: guards are typed for their own variables.
|
|
18
|
+
*/
|
|
19
|
+
guard: MiddlewareHandler;
|
|
20
|
+
/**
|
|
21
|
+
* Runs after `guard` on the routes that change something: pass the admin
|
|
22
|
+
* recent-sign-in check (`requireRecentAdminSignIn()` from `@awsmbiz/inbis-auth/hono`).
|
|
23
|
+
*/
|
|
24
|
+
changeGuard: MiddlewareHandler;
|
|
25
|
+
/**
|
|
26
|
+
* The signed-in admin's user ID, recorded as the actor of each change. Default `c.var.user?.id`.
|
|
27
|
+
* A change without one is refused with `401 UNAUTHENTICATED`.
|
|
28
|
+
*/
|
|
29
|
+
actorIdOf?: (c: Context<E>) => string | null | undefined;
|
|
30
|
+
/** Path prefix. Default `/api/admin`. */
|
|
31
|
+
basePath?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Mounts the role admin API (under `/api/admin` by default). Every response is
|
|
35
|
+
* `Cache-Control: no-store`; errors are `AppError`s, rendered as the standard
|
|
36
|
+
* envelope by `@awsmbiz/inbis-api`.
|
|
37
|
+
*
|
|
38
|
+
* Reads (`guard`):
|
|
39
|
+
* - `GET /permissions` → `{ items: [{ name, description }] }`
|
|
40
|
+
* - `GET /roles` → `{ items: RoleResponse[] }` (system roles first, then by name)
|
|
41
|
+
* - `GET /roles/:roleId` → `RoleResponse` (`404 ROLE_NOT_FOUND`)
|
|
42
|
+
* - `GET /users/:userId/roles` → `{ items: UserRoleResponse[] }` (`404 USER_NOT_FOUND`)
|
|
43
|
+
*
|
|
44
|
+
* Changes (`guard`, then `changeGuard`), each audited:
|
|
45
|
+
* - `POST /roles` `{ name, description?, permissions, inherits? }` → `201 RoleResponse`
|
|
46
|
+
* - `PATCH /roles/:roleId` (any of those fields) → `RoleResponse`
|
|
47
|
+
* - `DELETE /roles/:roleId` → `204` (its assignments go with it)
|
|
48
|
+
* - `PUT /users/:userId/roles/:roleId` → the user's roles (idempotent)
|
|
49
|
+
* - `DELETE /users/:userId/roles/:roleId` → the user's roles (idempotent)
|
|
50
|
+
*
|
|
51
|
+
* Refusals: `422 VALIDATION_FAILED` (`details.issues[{ path, message }]`),
|
|
52
|
+
* `422 UNKNOWN_PERMISSION` (`details.permissions`), `422 UNKNOWN_ROLE`
|
|
53
|
+
* (`details.roleIds`), `422 ROLE_INHERITANCE_CYCLE` (`details.cycle`),
|
|
54
|
+
* `409 ROLE_NAME_TAKEN` (`details.name`), `409 SYSTEM_ROLE_IMMUTABLE`,
|
|
55
|
+
* `409 ROLE_INHERITED` (`details.roleIds`), `404 ROLE_NOT_FOUND`,
|
|
56
|
+
* `404 USER_NOT_FOUND`, plus whatever the guards refuse with.
|
|
57
|
+
*/
|
|
58
|
+
export declare function mountRoleAdmin<E extends Env>(app: Hono<E>, options: MountRoleAdminOptions<E>): void;
|
|
59
|
+
/** Builds the access control for a request, from the app's bindings. */
|
|
60
|
+
export type AccessControlResolver<P extends string, E extends Env = Env> = (c: Context<E>) => AccessControl<P>;
|
|
61
|
+
/** Values put on the Hono context (`c.var`). */
|
|
62
|
+
export interface AccessControlVariables {
|
|
63
|
+
/** The request's access control (one per request: its answers are memoised for the request only). */
|
|
64
|
+
accessControl: AccessControl;
|
|
65
|
+
}
|
|
66
|
+
export interface HonoAccessControlOptions<E extends Env> {
|
|
67
|
+
/**
|
|
68
|
+
* The signed-in user's ID. Defaults to `c.var.user?.id`, as
|
|
69
|
+
* `@awsmbiz/inbis-auth/hono`'s `sessionMiddleware` sets it.
|
|
70
|
+
*/
|
|
71
|
+
userId?: (c: Context<E>) => string | null | undefined;
|
|
72
|
+
}
|
|
73
|
+
export interface HonoAccessControl<P extends string, E extends Env> {
|
|
74
|
+
/**
|
|
75
|
+
* Middleware: `401 UNAUTHENTICATED` without a signed-in user, `403
|
|
76
|
+
* FORBIDDEN` (`details.permission`) unless `authorize()` allows the
|
|
77
|
+
* permission; both `AppError`s, rendered as the error envelope by
|
|
78
|
+
* `@awsmbiz/inbis-api`. Put it after `requireAuth()`, which applies the session policy.
|
|
79
|
+
*/
|
|
80
|
+
requirePermission: (permission: P) => MiddlewareHandler<E>;
|
|
81
|
+
/** The request's access control (created, and the catalogue seeded, on first use). */
|
|
82
|
+
accessControlOf: (c: Context<E>) => Promise<AccessControl<P>>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Hono helpers over one resolver. Build once per Worker isolate: the
|
|
86
|
+
* catalogue is seeded on the first use (and again after a failed attempt).
|
|
87
|
+
*/
|
|
88
|
+
export declare function honoAccessControl<P extends string, E extends Env = Env>(resolve: AccessControlResolver<P, E>, options?: HonoAccessControlOptions<E>): HonoAccessControl<P, E>;
|
|
89
|
+
export type { MountRoleAdminOptions, RoleResponse, UserRoleResponse };
|
|
90
|
+
//# sourceMappingURL=hono.d.ts.map
|
package/dist/hono.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { i as parseRoleFields } from "./role-fields-ClqODoYn.js";
|
|
2
|
+
import { AppError, validationFailed } from "@awsmbiz/inbis-core";
|
|
3
|
+
// src/role-routes.ts
|
|
4
|
+
const toRoleResponse = (role) => ({
|
|
5
|
+
...role,
|
|
6
|
+
createdAt: role.createdAt.toISOString(),
|
|
7
|
+
updatedAt: role.updatedAt.toISOString()
|
|
8
|
+
});
|
|
9
|
+
const toUserRoles = (roles) => ({ items: roles.map((role) => ({
|
|
10
|
+
...role,
|
|
11
|
+
assignedAt: role.assignedAt.toISOString()
|
|
12
|
+
})) });
|
|
13
|
+
async function bodyOf(c) {
|
|
14
|
+
try {
|
|
15
|
+
return await c.req.json();
|
|
16
|
+
} catch {
|
|
17
|
+
throw validationFailed([{
|
|
18
|
+
path: [],
|
|
19
|
+
message: "Must be a JSON object."
|
|
20
|
+
}]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Mounts the role admin API (under `/api/admin` by default). Every response is
|
|
25
|
+
* `Cache-Control: no-store`; errors are `AppError`s, rendered as the standard
|
|
26
|
+
* envelope by `@awsmbiz/inbis-api`.
|
|
27
|
+
*
|
|
28
|
+
* Reads (`guard`):
|
|
29
|
+
* - `GET /permissions` → `{ items: [{ name, description }] }`
|
|
30
|
+
* - `GET /roles` → `{ items: RoleResponse[] }` (system roles first, then by name)
|
|
31
|
+
* - `GET /roles/:roleId` → `RoleResponse` (`404 ROLE_NOT_FOUND`)
|
|
32
|
+
* - `GET /users/:userId/roles` → `{ items: UserRoleResponse[] }` (`404 USER_NOT_FOUND`)
|
|
33
|
+
*
|
|
34
|
+
* Changes (`guard`, then `changeGuard`), each audited:
|
|
35
|
+
* - `POST /roles` `{ name, description?, permissions, inherits? }` → `201 RoleResponse`
|
|
36
|
+
* - `PATCH /roles/:roleId` (any of those fields) → `RoleResponse`
|
|
37
|
+
* - `DELETE /roles/:roleId` → `204` (its assignments go with it)
|
|
38
|
+
* - `PUT /users/:userId/roles/:roleId` → the user's roles (idempotent)
|
|
39
|
+
* - `DELETE /users/:userId/roles/:roleId` → the user's roles (idempotent)
|
|
40
|
+
*
|
|
41
|
+
* Refusals: `422 VALIDATION_FAILED` (`details.issues[{ path, message }]`),
|
|
42
|
+
* `422 UNKNOWN_PERMISSION` (`details.permissions`), `422 UNKNOWN_ROLE`
|
|
43
|
+
* (`details.roleIds`), `422 ROLE_INHERITANCE_CYCLE` (`details.cycle`),
|
|
44
|
+
* `409 ROLE_NAME_TAKEN` (`details.name`), `409 SYSTEM_ROLE_IMMUTABLE`,
|
|
45
|
+
* `409 ROLE_INHERITED` (`details.roleIds`), `404 ROLE_NOT_FOUND`,
|
|
46
|
+
* `404 USER_NOT_FOUND`, plus whatever the guards refuse with.
|
|
47
|
+
*/
|
|
48
|
+
function mountRoleAdmin(app, options) {
|
|
49
|
+
const base = options.basePath ?? "/api/admin";
|
|
50
|
+
const { guard, changeGuard } = options;
|
|
51
|
+
const own = (c) => c;
|
|
52
|
+
const access = (c) => options.accessControl(own(c));
|
|
53
|
+
const actorIdOf = options.actorIdOf ?? ((c) => c.get("user")?.id);
|
|
54
|
+
const actorOf = (c) => {
|
|
55
|
+
const id = actorIdOf(own(c));
|
|
56
|
+
if (id === null || id === void 0 || id === "") throw new AppError({
|
|
57
|
+
code: "UNAUTHENTICATED",
|
|
58
|
+
status: 401,
|
|
59
|
+
message: "Sign in to continue."
|
|
60
|
+
});
|
|
61
|
+
return id;
|
|
62
|
+
};
|
|
63
|
+
const hono = app;
|
|
64
|
+
const noStore = async (c, next) => {
|
|
65
|
+
c.header("cache-control", "no-store");
|
|
66
|
+
await next();
|
|
67
|
+
};
|
|
68
|
+
hono.get(`${base}/permissions`, guard, noStore, async (c) => c.json({ items: (await access(c)).listPermissions() }));
|
|
69
|
+
hono.get(`${base}/roles`, guard, noStore, async (c) => c.json({ items: (await (await access(c)).listRoles()).map(toRoleResponse) }));
|
|
70
|
+
hono.get(`${base}/roles/:roleId`, guard, noStore, async (c) => c.json(toRoleResponse(await (await access(c)).getRole(c.req.param("roleId")))));
|
|
71
|
+
hono.get(`${base}/users/:userId/roles`, guard, noStore, async (c) => c.json(toUserRoles(await (await access(c)).userRoles(c.req.param("userId")))));
|
|
72
|
+
hono.post(`${base}/roles`, guard, changeGuard, noStore, async (c) => {
|
|
73
|
+
const fields = parseRoleFields(await bodyOf(c));
|
|
74
|
+
const role = await (await access(c)).createRole({
|
|
75
|
+
...fields,
|
|
76
|
+
actorId: actorOf(c)
|
|
77
|
+
});
|
|
78
|
+
return c.json(toRoleResponse(role), 201);
|
|
79
|
+
});
|
|
80
|
+
hono.patch(`${base}/roles/:roleId`, guard, changeGuard, noStore, async (c) => {
|
|
81
|
+
const changes = parseRoleFields(await bodyOf(c), { partial: true });
|
|
82
|
+
const role = await (await access(c)).updateRole({
|
|
83
|
+
roleId: c.req.param("roleId"),
|
|
84
|
+
changes,
|
|
85
|
+
actorId: actorOf(c)
|
|
86
|
+
});
|
|
87
|
+
return c.json(toRoleResponse(role));
|
|
88
|
+
});
|
|
89
|
+
hono.delete(`${base}/roles/:roleId`, guard, changeGuard, noStore, async (c) => {
|
|
90
|
+
await (await access(c)).deleteRole({
|
|
91
|
+
roleId: c.req.param("roleId"),
|
|
92
|
+
actorId: actorOf(c)
|
|
93
|
+
});
|
|
94
|
+
return c.body(null, 204);
|
|
95
|
+
});
|
|
96
|
+
hono.put(`${base}/users/:userId/roles/:roleId`, guard, changeGuard, noStore, async (c) => {
|
|
97
|
+
const control = await access(c);
|
|
98
|
+
const userId = c.req.param("userId");
|
|
99
|
+
await control.assignRole({
|
|
100
|
+
userId,
|
|
101
|
+
roleId: c.req.param("roleId"),
|
|
102
|
+
actorId: actorOf(c)
|
|
103
|
+
});
|
|
104
|
+
return c.json(toUserRoles(await control.userRoles(userId)));
|
|
105
|
+
});
|
|
106
|
+
hono.delete(`${base}/users/:userId/roles/:roleId`, guard, changeGuard, noStore, async (c) => {
|
|
107
|
+
const control = await access(c);
|
|
108
|
+
const userId = c.req.param("userId");
|
|
109
|
+
await control.unassignRole({
|
|
110
|
+
userId,
|
|
111
|
+
roleId: c.req.param("roleId"),
|
|
112
|
+
actorId: actorOf(c)
|
|
113
|
+
});
|
|
114
|
+
return c.json(toUserRoles(await control.userRoles(userId)));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// src/hono.ts
|
|
118
|
+
/**
|
|
119
|
+
* Hono integration: `@awsmbiz/inbis-access-control/hono`.
|
|
120
|
+
*
|
|
121
|
+
* ```ts
|
|
122
|
+
* const { requirePermission } = honoAccessControl((c) =>
|
|
123
|
+
* createAccessControl(c.env.ACCESS_CONTROL_DB, access, { audit, requestId: c.var.requestId }),
|
|
124
|
+
* );
|
|
125
|
+
* app.get("/v1/notes", requireAuth(), requirePermission("notes.read"), handler);
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* Hono helpers over one resolver. Build once per Worker isolate: the
|
|
130
|
+
* catalogue is seeded on the first use (and again after a failed attempt).
|
|
131
|
+
*/
|
|
132
|
+
function honoAccessControl(resolve, options = {}) {
|
|
133
|
+
let seeded;
|
|
134
|
+
const userIdOf = options.userId ?? ((c) => c.get("user")?.id);
|
|
135
|
+
async function accessControlOf(c) {
|
|
136
|
+
const ctx = c;
|
|
137
|
+
const existing = ctx.get("accessControl");
|
|
138
|
+
if (existing !== void 0) return existing;
|
|
139
|
+
const access = resolve(c);
|
|
140
|
+
seeded ??= access.seed().catch((error) => {
|
|
141
|
+
seeded = void 0;
|
|
142
|
+
throw error;
|
|
143
|
+
});
|
|
144
|
+
await seeded;
|
|
145
|
+
ctx.set("accessControl", access);
|
|
146
|
+
return access;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
accessControlOf,
|
|
150
|
+
requirePermission: (permission) => async (c, next) => {
|
|
151
|
+
const userId = userIdOf(c);
|
|
152
|
+
if (userId === null || userId === void 0) throw new AppError({
|
|
153
|
+
code: "UNAUTHENTICATED",
|
|
154
|
+
status: 401,
|
|
155
|
+
message: "Sign in to continue."
|
|
156
|
+
});
|
|
157
|
+
if (!await (await accessControlOf(c)).authorize({
|
|
158
|
+
userId,
|
|
159
|
+
permission
|
|
160
|
+
})) throw new AppError({
|
|
161
|
+
code: "FORBIDDEN",
|
|
162
|
+
status: 403,
|
|
163
|
+
message: "You do not have permission to do this.",
|
|
164
|
+
details: { permission }
|
|
165
|
+
});
|
|
166
|
+
await next();
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
export { honoAccessControl, mountRoleAdmin };
|
|
171
|
+
|
|
172
|
+
//# sourceMappingURL=hono.js.map
|
package/dist/hono.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hono.js","names":[],"sources":["../src/role-routes.ts","../src/hono.ts"],"sourcesContent":["import { AppError, validationFailed } from \"@awsmbiz/inbis-core\";\nimport type { Context, Env, Hono, MiddlewareHandler } from \"hono\";\nimport type { AccessControl } from \"./access-control.js\";\nimport { parseRoleFields } from \"./role-fields.js\";\nimport type { Role, UserRole } from \"./roles.js\";\n\n/** A role as the admin API returns it: {@link Role} with ISO 8601 timestamps. */\nexport interface RoleResponse extends Omit<Role, \"createdAt\" | \"updatedAt\"> {\n createdAt: string;\n updatedAt: string;\n}\n\n/** A user's role as the admin API returns it: {@link UserRole} with an ISO 8601 `assignedAt`. */\nexport interface UserRoleResponse extends Omit<UserRole, \"assignedAt\"> {\n assignedAt: string;\n}\n\nexport interface MountRoleAdminOptions<E extends Env = Env> {\n /** The request's access control, e.g. `accessControlOf` from {@link honoAccessControl} (seeds the catalogue). */\n accessControl: (c: Context<E>) => Promise<AccessControl>;\n /**\n * Admits the caller to every route, or throws: pass the app's admin policy\n * (`requireAdmin()` from `@awsmbiz/inbis-auth/hono`). Any env: guards are typed for their own variables.\n */\n guard: MiddlewareHandler;\n /**\n * Runs after `guard` on the routes that change something: pass the admin\n * recent-sign-in check (`requireRecentAdminSignIn()` from `@awsmbiz/inbis-auth/hono`).\n */\n changeGuard: MiddlewareHandler;\n /**\n * The signed-in admin's user ID, recorded as the actor of each change. Default `c.var.user?.id`.\n * A change without one is refused with `401 UNAUTHENTICATED`.\n */\n actorIdOf?: (c: Context<E>) => string | null | undefined;\n /** Path prefix. Default `/api/admin`. */\n basePath?: string;\n}\n\nconst toRoleResponse = (role: Role): RoleResponse => ({\n ...role,\n createdAt: role.createdAt.toISOString(),\n updatedAt: role.updatedAt.toISOString(),\n});\n\nconst toUserRoles = (roles: UserRole[]) => ({\n items: roles.map((role): UserRoleResponse => ({ ...role, assignedAt: role.assignedAt.toISOString() })),\n});\n\nasync function bodyOf(c: Context): Promise<unknown> {\n try {\n return await c.req.json();\n } catch {\n throw validationFailed([{ path: [], message: \"Must be a JSON object.\" }]);\n }\n}\n\n/**\n * Mounts the role admin API (under `/api/admin` by default). Every response is\n * `Cache-Control: no-store`; errors are `AppError`s, rendered as the standard\n * envelope by `@awsmbiz/inbis-api`.\n *\n * Reads (`guard`):\n * - `GET /permissions` → `{ items: [{ name, description }] }`\n * - `GET /roles` → `{ items: RoleResponse[] }` (system roles first, then by name)\n * - `GET /roles/:roleId` → `RoleResponse` (`404 ROLE_NOT_FOUND`)\n * - `GET /users/:userId/roles` → `{ items: UserRoleResponse[] }` (`404 USER_NOT_FOUND`)\n *\n * Changes (`guard`, then `changeGuard`), each audited:\n * - `POST /roles` `{ name, description?, permissions, inherits? }` → `201 RoleResponse`\n * - `PATCH /roles/:roleId` (any of those fields) → `RoleResponse`\n * - `DELETE /roles/:roleId` → `204` (its assignments go with it)\n * - `PUT /users/:userId/roles/:roleId` → the user's roles (idempotent)\n * - `DELETE /users/:userId/roles/:roleId` → the user's roles (idempotent)\n *\n * Refusals: `422 VALIDATION_FAILED` (`details.issues[{ path, message }]`),\n * `422 UNKNOWN_PERMISSION` (`details.permissions`), `422 UNKNOWN_ROLE`\n * (`details.roleIds`), `422 ROLE_INHERITANCE_CYCLE` (`details.cycle`),\n * `409 ROLE_NAME_TAKEN` (`details.name`), `409 SYSTEM_ROLE_IMMUTABLE`,\n * `409 ROLE_INHERITED` (`details.roleIds`), `404 ROLE_NOT_FOUND`,\n * `404 USER_NOT_FOUND`, plus whatever the guards refuse with.\n */\nexport function mountRoleAdmin<E extends Env>(app: Hono<E>, options: MountRoleAdminOptions<E>): void {\n const base = options.basePath ?? \"/api/admin\";\n const { guard, changeGuard } = options;\n // Routes run with the app's env E; Hono types the plain app's handlers with `any`.\n const own = (c: Context): Context<E> => c as Context<E>;\n const access = (c: Context) => options.accessControl(own(c));\n const actorIdOf =\n options.actorIdOf ??\n ((c: Context<E>) => (c as unknown as Context<{ Variables: { user?: { id: string } | null } }>).get(\"user\")?.id);\n // Fails closed: a change without a signed-in admin is refused, never audited without an actor.\n const actorOf = (c: Context): string => {\n const id = actorIdOf(own(c));\n if (id === null || id === undefined || id === \"\") {\n throw new AppError({ code: \"UNAUTHENTICATED\", status: 401, message: \"Sign in to continue.\" });\n }\n return id;\n };\n // Hono's handler types do not compose over a generic env: register on the plain app.\n const hono = app as unknown as Hono;\n const noStore: MiddlewareHandler = async (c, next) => {\n c.header(\"cache-control\", \"no-store\");\n await next();\n };\n\n hono.get(`${base}/permissions`, guard, noStore, async (c) => c.json({ items: (await access(c)).listPermissions() }));\n hono.get(`${base}/roles`, guard, noStore, async (c) => c.json({ items: (await (await access(c)).listRoles()).map(toRoleResponse) }));\n hono.get(`${base}/roles/:roleId`, guard, noStore, async (c) => c.json(toRoleResponse(await (await access(c)).getRole(c.req.param(\"roleId\")))));\n hono.get(`${base}/users/:userId/roles`, guard, noStore, async (c) =>\n c.json(toUserRoles(await (await access(c)).userRoles(c.req.param(\"userId\")))),\n );\n\n hono.post(`${base}/roles`, guard, changeGuard, noStore, async (c) => {\n const fields = parseRoleFields(await bodyOf(c));\n const role = await (await access(c)).createRole({ ...fields, actorId: actorOf(c) });\n return c.json(toRoleResponse(role), 201);\n });\n hono.patch(`${base}/roles/:roleId`, guard, changeGuard, noStore, async (c) => {\n const changes = parseRoleFields(await bodyOf(c), { partial: true });\n const role = await (await access(c)).updateRole({ roleId: c.req.param(\"roleId\"), changes, actorId: actorOf(c) });\n return c.json(toRoleResponse(role));\n });\n hono.delete(`${base}/roles/:roleId`, guard, changeGuard, noStore, async (c) => {\n await (await access(c)).deleteRole({ roleId: c.req.param(\"roleId\"), actorId: actorOf(c) });\n return c.body(null, 204);\n });\n hono.put(`${base}/users/:userId/roles/:roleId`, guard, changeGuard, noStore, async (c) => {\n const control = await access(c);\n const userId = c.req.param(\"userId\");\n await control.assignRole({ userId, roleId: c.req.param(\"roleId\"), actorId: actorOf(c) });\n return c.json(toUserRoles(await control.userRoles(userId)));\n });\n hono.delete(`${base}/users/:userId/roles/:roleId`, guard, changeGuard, noStore, async (c) => {\n const control = await access(c);\n const userId = c.req.param(\"userId\");\n await control.unassignRole({ userId, roleId: c.req.param(\"roleId\"), actorId: actorOf(c) });\n return c.json(toUserRoles(await control.userRoles(userId)));\n });\n}\n","/**\n * Hono integration: `@awsmbiz/inbis-access-control/hono`.\n *\n * ```ts\n * const { requirePermission } = honoAccessControl((c) =>\n * createAccessControl(c.env.ACCESS_CONTROL_DB, access, { audit, requestId: c.var.requestId }),\n * );\n * app.get(\"/v1/notes\", requireAuth(), requirePermission(\"notes.read\"), handler);\n * ```\n */\nimport { AppError } from \"@awsmbiz/inbis-core\";\nimport type { Context, Env, MiddlewareHandler } from \"hono\";\nimport type { AccessControl } from \"./access-control.js\";\n\n/** Builds the access control for a request, from the app's bindings. */\nexport type AccessControlResolver<P extends string, E extends Env = Env> = (c: Context<E>) => AccessControl<P>;\n\n/** Values put on the Hono context (`c.var`). */\nexport interface AccessControlVariables {\n /** The request's access control (one per request: its answers are memoised for the request only). */\n accessControl: AccessControl;\n}\n\nexport interface HonoAccessControlOptions<E extends Env> {\n /**\n * The signed-in user's ID. Defaults to `c.var.user?.id`, as\n * `@awsmbiz/inbis-auth/hono`'s `sessionMiddleware` sets it.\n */\n userId?: (c: Context<E>) => string | null | undefined;\n}\n\nexport interface HonoAccessControl<P extends string, E extends Env> {\n /**\n * Middleware: `401 UNAUTHENTICATED` without a signed-in user, `403\n * FORBIDDEN` (`details.permission`) unless `authorize()` allows the\n * permission; both `AppError`s, rendered as the error envelope by\n * `@awsmbiz/inbis-api`. Put it after `requireAuth()`, which applies the session policy.\n */\n requirePermission: (permission: P) => MiddlewareHandler<E>;\n /** The request's access control (created, and the catalogue seeded, on first use). */\n accessControlOf: (c: Context<E>) => Promise<AccessControl<P>>;\n}\n\n/**\n * Hono helpers over one resolver. Build once per Worker isolate: the\n * catalogue is seeded on the first use (and again after a failed attempt).\n */\nexport function honoAccessControl<P extends string, E extends Env = Env>(\n resolve: AccessControlResolver<P, E>,\n options: HonoAccessControlOptions<E> = {},\n): HonoAccessControl<P, E> {\n let seeded: Promise<unknown> | undefined;\n const userIdOf =\n options.userId ?? ((c: Context<E>) => (c as unknown as Context<{ Variables: { user?: { id: string } | null } }>).get(\"user\")?.id);\n\n async function accessControlOf(c: Context<E>): Promise<AccessControl<P>> {\n const ctx = c as unknown as Context<{ Variables: AccessControlVariables }>;\n const existing = ctx.get(\"accessControl\") as AccessControl<P> | undefined;\n if (existing !== undefined) return existing;\n const access = resolve(c);\n seeded ??= access.seed().catch((error: unknown) => {\n seeded = undefined;\n throw error;\n });\n await seeded;\n ctx.set(\"accessControl\", access as AccessControl);\n return access;\n }\n\n return {\n accessControlOf,\n requirePermission: (permission) => async (c, next) => {\n const userId = userIdOf(c);\n if (userId === null || userId === undefined) {\n throw new AppError({ code: \"UNAUTHENTICATED\", status: 401, message: \"Sign in to continue.\" });\n }\n const access = await accessControlOf(c);\n if (!(await access.authorize({ userId, permission }))) {\n throw new AppError({\n code: \"FORBIDDEN\",\n status: 403,\n message: \"You do not have permission to do this.\",\n details: { permission },\n });\n }\n await next();\n },\n };\n}\nexport { mountRoleAdmin, type MountRoleAdminOptions, type RoleResponse, type UserRoleResponse } from \"./role-routes.js\";\n"],"mappings":"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"}
|