@fabrika/iam-contract 0.0.1

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.
Files changed (2) hide show
  1. package/package.json +30 -0
  2. package/src/index.ts +496 -0
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@fabrika/iam-contract",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/fabrika-platform.git",
8
+ "directory": "packages/iam-contract"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "bun": "./src/index.ts",
19
+ "types": "./src/index.ts",
20
+ "default": "./src/index.ts"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "@fabrika/app": "^0.0.1",
28
+ "@fabrika/auth-core": "^0.0.1"
29
+ }
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,496 @@
1
+ // @fabrika/iam-contract — the runtime-neutral IAM administration contract.
2
+ //
3
+ // `IamAdminRpcContract` at the bottom is the WHOLE administration API: one transport, so policy,
4
+ // audit and hidden-object behaviour cannot diverge between two of them. What remains of `/admin/*`
5
+ // REST is machine provisioning — an app's schema reconcile (`PutAppSchemaRequest`) and the
6
+ // first-machine-caller bootstrap (`ProvisionApiKeyRequest`) — and it shares these same shapes.
7
+ //
8
+ // Shared by @fabrika/iam and its browser clients. Reuses @fabrika/auth-core types
9
+ // where they fit. Keep these clean and complete: they are the admin API contract.
10
+
11
+ import type { RpcProcedureContract } from '@fabrika/app'
12
+ import type { AppActionDef, AppSchema, AppScopeDef, PermissionEntry, PermissionSource, PrincipalType, RoleDef } from '@fabrika/auth-core'
13
+
14
+ // ── Common wrappers ───────────────────────────────────────────────────────────
15
+
16
+ /** A plain list response. */
17
+ export interface ListResponse<T> {
18
+ items: T[]
19
+ }
20
+
21
+ /**
22
+ * A cursor-paginated list. `nextCursor` is the opaque cursor to pass as `before`
23
+ * for the next page; null when there are no more rows.
24
+ */
25
+ export interface CursorList<T> {
26
+ items: T[]
27
+ nextCursor: string | null
28
+ }
29
+
30
+ // ── Principals ────────────────────────────────────────────────────────────────
31
+
32
+ /** Derived principal status — invited (unclaimed) → active → disabled. */
33
+ export type PrincipalStatus = 'invited' | 'active' | 'disabled'
34
+
35
+ export interface PrincipalListItem {
36
+ id: string
37
+ type: PrincipalType
38
+ label: string
39
+ email: string | null
40
+ externalId: string | null
41
+ status: PrincipalStatus
42
+ createdAt: number
43
+ }
44
+
45
+ export interface GrantDto {
46
+ id: string
47
+ principalId: string
48
+ /** Named role/policy key; null for an inline grant (then `permissions` is set). */
49
+ roleKey: string | null
50
+ /** Inline action-pattern set; null for a role-based grant (then `roleKey` is set). */
51
+ permissions: string[] | null
52
+ /** Scope dimension; null = global (both rise/fall together). */
53
+ scopeType: string | null
54
+ /** Opaque, app-owned scope value; null = global. */
55
+ scopeValue: string | null
56
+ /** App id this grant applies to; null = all apps (cross-app). */
57
+ app: string | null
58
+ grantedBy: string | null
59
+ expiresAt: number | null
60
+ createdAt: number
61
+ /** True when `roleKey` is set but no longer resolves to a known role (zero perms). */
62
+ dangling: boolean
63
+ }
64
+
65
+ export interface PrincipalDetail extends PrincipalListItem {
66
+ grants: GrantDto[]
67
+ /** Effective permissions with `source` (grant / bootstrap / group:<ref>). */
68
+ permissions: PermissionEntry[]
69
+ authentication: {
70
+ /** OIDC is linked when this user has claimed an identity-provider subject. */
71
+ oidc: { state: 'unavailable' | 'unlinked' | 'linked' }
72
+ /** Password access is globally opt-in and then enabled per user. */
73
+ password: { state: 'unavailable' | 'disabled' | 'pending' | 'enabled' }
74
+ }
75
+ }
76
+
77
+ export interface InviteRequest {
78
+ email: string
79
+ }
80
+
81
+ export interface UpdatePrincipalRequest {
82
+ disabled: boolean
83
+ }
84
+
85
+ // ── Sessions ──────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * A browser session, as an operator sees it. Never the cookie or its hash — a session is addressed
89
+ * by `id`, which is not a credential.
90
+ */
91
+ export interface SessionDto {
92
+ id: string
93
+ principalId: string
94
+ /** How the human proved who they were; `local_dev` is IAM's `LOCAL_DEV_LOGIN` bypass. */
95
+ authenticationMethod: 'oidc' | 'password' | 'local_dev'
96
+ /** The app this session is bound to (an ADR-0021 child); null = the IAM session itself. */
97
+ app: string | null
98
+ /** The IAM session this was derived from; null on an IAM session. Revoking that one revokes this. */
99
+ parentSessionId: string | null
100
+ /** `active` until it is revoked or its expiry passes. A revoked session reads `revoked`. */
101
+ status: 'active' | 'revoked' | 'expired'
102
+ createdAt: number
103
+ expiresAt: number
104
+ revokedAt: number | null
105
+ }
106
+
107
+ // ── Grants ────────────────────────────────────────────────────────────────────
108
+
109
+ export interface CreateGrantRequest {
110
+ principalId: string
111
+ /** A named role/policy key — XOR `permissions` (supply exactly one). */
112
+ roleKey?: string
113
+ /** An inline action-pattern set — XOR `roleKey` (supply exactly one). */
114
+ permissions?: string[]
115
+ /** Scope dimension; null / omitted = global. Both-or-neither with `scopeValue`. */
116
+ scopeType?: string | null
117
+ /** Opaque, app-owned scope value; null / omitted = global. */
118
+ scopeValue?: string | null
119
+ /** App id (a registered app id); null / omitted = all apps (cross-app). */
120
+ app?: string | null
121
+ expiresAt?: number | null
122
+ }
123
+
124
+ // ── Apps (read-only; the live registry derived from the schema tables) ────────
125
+
126
+ /** The set of app ids propustka knows about — the choices for a grant's `app`. */
127
+ export interface AppDto {
128
+ id: string
129
+ }
130
+
131
+ // ── Roles & policies (per-app, DB-backed) ─────────────────────────────────────
132
+
133
+ /** A role available for the calling app — a built-in or a DB row (app/custom). */
134
+ export interface RoleDto {
135
+ key: string
136
+ name: string
137
+ description?: string
138
+ permissions: string[]
139
+ /** 'builtin' (cross-app, e.g. admin), 'app' (reconciled), or 'custom' (admin-made). */
140
+ origin: 'builtin' | 'app' | 'custom'
141
+ }
142
+
143
+ // ── App schema (reconciled vocabulary) ────────────────────────────────────────
144
+
145
+ /**
146
+ * The body of `PUT /admin/apps/:app/schema` — one of the two surviving REST operations, because a
147
+ * deploy step reconciles an app's vocabulary from outside the installation. It IS the core
148
+ * `AppSchema`; `reconcileSchema` in `@fabrika/auth` sends exactly this.
149
+ */
150
+ export type PutAppSchemaRequest = AppSchema
151
+
152
+ /** GET schema response — the app's scopes, actions, and origin='app' roles. */
153
+ export interface AppSchemaDto {
154
+ app: string
155
+ scopes: AppScopeDef[]
156
+ actions: AppActionDef[]
157
+ /** role_key -> def, origin='app' only (reconciled from code). */
158
+ roles: Record<string, RoleDef>
159
+ }
160
+
161
+ // ── Policies (origin='custom' roles, admin-composed) ──────────────────────────
162
+
163
+ /** A custom policy (an origin='custom' role row) for an app. */
164
+ export interface PolicyDto {
165
+ app: string
166
+ key: string
167
+ name: string
168
+ description?: string
169
+ permissions: string[]
170
+ createdAt: number
171
+ }
172
+
173
+ export interface CreatePolicyRequest {
174
+ key: string
175
+ name: string
176
+ description?: string
177
+ permissions: string[]
178
+ }
179
+
180
+ export interface UpdatePolicyRequest {
181
+ name: string
182
+ description?: string
183
+ permissions: string[]
184
+ }
185
+
186
+ // ── API keys (native service credentials) ─────────────────────────────────────
187
+
188
+ /** Metadata view of a service-principal API key (the secret is never returned here). */
189
+ export interface ApiKeyDto {
190
+ principalId: string
191
+ label: string
192
+ status: PrincipalStatus
193
+ grants: GrantDto[]
194
+ createdAt: number
195
+ }
196
+
197
+ export interface ProvisionApiKeyRequest {
198
+ label: string
199
+ type: 'service'
200
+ /** A named role/policy key — XOR `permissions` (supply exactly one). */
201
+ roleKey?: string
202
+ /** An inline action-pattern set — XOR `roleKey` (supply exactly one). */
203
+ permissions?: string[]
204
+ /** Scope dimension; null / omitted = global. Both-or-neither with `scopeValue`. */
205
+ scopeType?: string | null
206
+ /** Opaque, app-owned scope value; null / omitted = global. */
207
+ scopeValue?: string | null
208
+ /** App id (a registered app id); null / omitted = all apps. */
209
+ app?: string | null
210
+ expiresAt?: number | null
211
+ }
212
+
213
+ /**
214
+ * Provisioning result. `apiKey` (a `px_…` credential bound to the new service principal) is shown
215
+ * exactly ONCE — copy it now, it is not retrievable later. Carried as a bearer (or a declared
216
+ * header) directly to apps on the propustka-native path (`mintFromKey`), no Cloudflare Access in
217
+ * front.
218
+ */
219
+ export interface ProvisionApiKeyResponse {
220
+ principalId: string
221
+ apiKey: string
222
+ }
223
+
224
+ /** Rotation result — new key shown once; principal + grants unchanged, old key stops working. */
225
+ export interface RotateApiKeyResponse {
226
+ principalId: string
227
+ apiKey: string
228
+ }
229
+
230
+ // ── Share links (anonymous credentials) ─────────────────────────────────────────
231
+
232
+ /** Metadata only — never the token hash or plaintext. A share link is an anonymous credential. */
233
+ export interface ShareLinkListItem {
234
+ id: string
235
+ label: string | null
236
+ /** The app this link mints for. Never null on a link issued after the app binding (SEC-2). */
237
+ app: string | null
238
+ issuedBy: string | null
239
+ expiresAt: number | null
240
+ revokedAt: number | null
241
+ createdAt: number
242
+ /** Frozen inline grants — matched by `permits` (action + optional scope), not exact resource. */
243
+ grants: { action: string; scope: { type: string; value: string } | null }[]
244
+ }
245
+
246
+ export interface IssueShareLinkRequest {
247
+ /**
248
+ * The app the link mints for. REQUIRED: an anonymous credential's grants are frozen at issue, so
249
+ * the app they were delegated against is the only thing that keeps them from being spent at every
250
+ * other app behind the proxy.
251
+ */
252
+ app: string
253
+ /** Each grant is delegation-checked against the issuer on its `scope` (omitted → global). */
254
+ grants: { action: string; scope?: { type: string; value: string } | null }[]
255
+ label?: string
256
+ expiresAt?: number
257
+ }
258
+
259
+ /** Issued share-link result — the plaintext `px_` token returned ONCE. */
260
+ export interface IssuedShareLinkResponse {
261
+ id: string
262
+ token: string
263
+ }
264
+
265
+ // ── Audit & auth log ──────────────────────────────────────────────────────────
266
+
267
+ export interface AuditEventDto {
268
+ id: string
269
+ requestId: string
270
+ principalId: string | null
271
+ principalLabel: string
272
+ credentialId: string | null
273
+ app: string
274
+ action: string
275
+ resourceType: string
276
+ resourceId: string | null
277
+ diff: unknown
278
+ metadata: unknown
279
+ createdAt: number
280
+ }
281
+
282
+ export interface AuthLogDto {
283
+ id: number
284
+ requestId: string
285
+ app: string
286
+ kind: 'authenticate'
287
+ principalId: string | null
288
+ credentialId: string | null
289
+ decision: 'allow' | 'deny'
290
+ reason: string | null
291
+ createdAt: number
292
+ }
293
+
294
+ // ── Me ────────────────────────────────────────────────────────────────────────
295
+
296
+ export interface MeDto {
297
+ id: string
298
+ type: PrincipalType
299
+ label: string
300
+ permissions: PermissionEntry[]
301
+ }
302
+
303
+ // ── Typed admin RPC ──────────────────────────────────────────────────────────
304
+
305
+ export interface OkResponse {
306
+ ok: true
307
+ }
308
+
309
+ /** Every admin list takes the same page coordinates: an opaque `before` cursor and a row `limit`. */
310
+ export interface PageInput {
311
+ before?: string
312
+ limit?: number
313
+ }
314
+
315
+ export interface ListPrincipalsInput extends PageInput {
316
+ type?: PrincipalType
317
+ status?: PrincipalStatus
318
+ q?: string
319
+ }
320
+
321
+ export type ListApiKeysInput = PageInput
322
+
323
+ export interface ListShareLinksInput extends PageInput {
324
+ /** Only links minting for this app. */
325
+ app?: string
326
+ /** Revoked links are hidden by default; the console asks for them explicitly. */
327
+ includeRevoked?: boolean
328
+ }
329
+
330
+ export interface RotateApiKeyInput extends ApiKeyIdInput {
331
+ /**
332
+ * New absolute expiry (unix seconds), or `null` for none. OMITTED carries the replaced
333
+ * credential's expiry forward — rotation replaces a secret, it does not extend a lifetime.
334
+ */
335
+ expiresAt?: number | null
336
+ }
337
+
338
+ export interface PrincipalIdInput {
339
+ id: string
340
+ }
341
+
342
+ export interface ListSessionsInput extends PageInput {
343
+ /** Whose sessions to list. */
344
+ principalId: string
345
+ }
346
+
347
+ export interface SessionIdInput {
348
+ id: string
349
+ }
350
+
351
+ /** How many rows the call actually revoked; an already-revoked session is not counted again. */
352
+ export interface RevokedSessionsResponse {
353
+ revoked: number
354
+ }
355
+
356
+ export interface UpdatePrincipalInput extends UpdatePrincipalRequest, PrincipalIdInput {}
357
+
358
+ /** Password action sent by email, or a one-time URL shown only in this response. */
359
+ export type PasswordActionDelivery =
360
+ | { delivery: 'email'; email: string; expiresAt: number }
361
+ | { delivery: 'manual'; url: string; expiresAt: number }
362
+
363
+ export interface GrantIdInput {
364
+ id: string
365
+ }
366
+
367
+ export interface AppInput {
368
+ app: string
369
+ }
370
+
371
+ /**
372
+ * The public origins IAM will hand this app a session at (ADR-0021). Declarative: the whole set is
373
+ * stated, so removing one is the same call as adding one and a stale entry cannot survive a deploy.
374
+ */
375
+ export interface SetReturnOriginsRequest {
376
+ app: string
377
+ origins: string[]
378
+ }
379
+
380
+ export interface ReturnOriginsDto {
381
+ app: string
382
+ /** Canonicalized: lower-cased scheme and host, default port dropped, no path. */
383
+ origins: string[]
384
+ }
385
+
386
+ export interface ListRolesInput {
387
+ app?: string | null
388
+ }
389
+
390
+ export interface CreatePolicyInput {
391
+ app: string
392
+ policy: CreatePolicyRequest
393
+ }
394
+
395
+ export interface UpdatePolicyInput {
396
+ app: string
397
+ key: string
398
+ policy: UpdatePolicyRequest
399
+ }
400
+
401
+ export interface PolicyIdInput {
402
+ app: string
403
+ key: string
404
+ }
405
+
406
+ export interface ApiKeyIdInput {
407
+ principalId: string
408
+ }
409
+
410
+ export interface ShareLinkIdInput {
411
+ id: string
412
+ }
413
+
414
+ export interface ListAuditInput {
415
+ resourceType?: string
416
+ resourceId?: string
417
+ principalId?: string
418
+ action?: string
419
+ requestId?: string
420
+ before?: string
421
+ limit?: number
422
+ }
423
+
424
+ export interface ListAuthLogInput {
425
+ principalId?: string
426
+ requestId?: string
427
+ decision?: 'allow' | 'deny'
428
+ before?: string
429
+ limit?: number
430
+ }
431
+
432
+ type RpcProcedure<TInput, TOutput> = RpcProcedureContract<TInput, TOutput>
433
+
434
+ /** Browser-safe typed contract for the IAM administration API. */
435
+ export interface IamAdminRpcContract {
436
+ me: RpcProcedure<void, MeDto>
437
+ principals: {
438
+ list: RpcProcedure<ListPrincipalsInput, CursorList<PrincipalListItem>>
439
+ get: RpcProcedure<PrincipalIdInput, PrincipalDetail>
440
+ invite: RpcProcedure<InviteRequest, PrincipalListItem>
441
+ update: RpcProcedure<UpdatePrincipalInput, PrincipalListItem>
442
+ /** Cancel an unclaimed invite, or disable a claimed principal. */
443
+ delete: RpcProcedure<PrincipalIdInput, OkResponse>
444
+ }
445
+ /**
446
+ * Sessions an operator no longer trusts. Revoking an IAM session also ends every app session
447
+ * derived from it — a child is only valid while its parent is — so this is one call, not a sweep.
448
+ */
449
+ sessions: {
450
+ list: RpcProcedure<ListSessionsInput, CursorList<SessionDto>>
451
+ revoke: RpcProcedure<SessionIdInput, RevokedSessionsResponse>
452
+ revokeAll: RpcProcedure<PrincipalIdInput, RevokedSessionsResponse>
453
+ }
454
+ passwords: {
455
+ issueEnrollment: RpcProcedure<PrincipalIdInput, PasswordActionDelivery>
456
+ issueReset: RpcProcedure<PrincipalIdInput, PasswordActionDelivery>
457
+ disable: RpcProcedure<PrincipalIdInput, OkResponse>
458
+ }
459
+ grants: {
460
+ create: RpcProcedure<CreateGrantRequest, GrantDto>
461
+ delete: RpcProcedure<GrantIdInput, OkResponse>
462
+ }
463
+ apps: {
464
+ list: RpcProcedure<void, ListResponse<AppDto>>
465
+ getSchema: RpcProcedure<AppInput, AppSchemaDto>
466
+ getReturnOrigins: RpcProcedure<AppInput, ReturnOriginsDto>
467
+ setReturnOrigins: RpcProcedure<SetReturnOriginsRequest, ReturnOriginsDto>
468
+ clearReturnOrigins: RpcProcedure<AppInput, ReturnOriginsDto>
469
+ }
470
+ roles: {
471
+ list: RpcProcedure<ListRolesInput, ListResponse<RoleDto>>
472
+ }
473
+ policies: {
474
+ list: RpcProcedure<AppInput, ListResponse<PolicyDto>>
475
+ create: RpcProcedure<CreatePolicyInput, PolicyDto>
476
+ update: RpcProcedure<UpdatePolicyInput, PolicyDto>
477
+ delete: RpcProcedure<PolicyIdInput, OkResponse>
478
+ }
479
+ apiKeys: {
480
+ list: RpcProcedure<ListApiKeysInput, CursorList<ApiKeyDto>>
481
+ provision: RpcProcedure<ProvisionApiKeyRequest, ProvisionApiKeyResponse>
482
+ rotate: RpcProcedure<RotateApiKeyInput, RotateApiKeyResponse>
483
+ revoke: RpcProcedure<ApiKeyIdInput, OkResponse>
484
+ }
485
+ shareLinks: {
486
+ list: RpcProcedure<ListShareLinksInput, CursorList<ShareLinkListItem>>
487
+ issue: RpcProcedure<IssueShareLinkRequest, IssuedShareLinkResponse>
488
+ revoke: RpcProcedure<ShareLinkIdInput, OkResponse>
489
+ }
490
+ audit: {
491
+ list: RpcProcedure<ListAuditInput, CursorList<AuditEventDto>>
492
+ listAuthLog: RpcProcedure<ListAuthLogInput, CursorList<AuthLogDto>>
493
+ }
494
+ }
495
+
496
+ export type { AppActionDef, AppSchema, AppScopeDef, PermissionEntry, PermissionSource, PrincipalType, RoleDef }