@kindgi/authz 0.0.0-bootstrap.0 → 0.1.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.
package/src/tuples.ts ADDED
@@ -0,0 +1,595 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ //
5
+ // FGA VOCABULARY — builds the OpenFGA `user`/`relation`/`object` tuple
6
+ // strings. Callers deal in typed EntityRefs and call tuplesForCreate /
7
+ // tuplesForDelete and the grant helpers below; principal subjects come
8
+ // from `fgaSubject` (`principal.ts`).
9
+ //
10
+ // Keeping the tuple format here means another authorization backend
11
+ // changes this file, not its callers.
12
+ //
13
+
14
+ import type {
15
+ AgentId,
16
+ FlowId,
17
+ GuardrailId,
18
+ OrgId,
19
+ ProjectId,
20
+ RunId,
21
+ TeamId,
22
+ TenantId,
23
+ ToolId,
24
+ UserId,
25
+ } from '@kindgi/types';
26
+
27
+ import type { TupleIntent } from './enqueue.js';
28
+
29
+ // @kindgi/types has no branded EvalSuiteId / SecretId / EnvId /
30
+ // McpEndpointId; these aliases are plain strings.
31
+ type EvalSuiteId = string;
32
+ type SecretId = string;
33
+ type EnvId = string;
34
+ type McpEndpointId = string;
35
+
36
+ // ============================================================
37
+ // SUBJECT HELPERS
38
+ // ============================================================
39
+
40
+ export function userSubject(id: UserId): string {
41
+ return `user:${id}`;
42
+ }
43
+
44
+ export function teamMemberSubject(id: TeamId): string {
45
+ return `team:${id}#member`;
46
+ }
47
+
48
+ // ============================================================
49
+ // OBJECT HELPERS
50
+ // ============================================================
51
+
52
+ export function tenantObject(id: TenantId): string {
53
+ return `tenant:${id}`;
54
+ }
55
+
56
+ export function orgObject(id: OrgId): string {
57
+ return `org:${id}`;
58
+ }
59
+
60
+ export function teamObject(id: TeamId): string {
61
+ return `team:${id}`;
62
+ }
63
+
64
+ export function projectObject(id: ProjectId): string {
65
+ return `project:${id}`;
66
+ }
67
+
68
+ export function agentObject(id: AgentId): string {
69
+ return `agent:${id}`;
70
+ }
71
+
72
+ export function flowObject(id: FlowId): string {
73
+ return `flow:${id}`;
74
+ }
75
+
76
+ export function toolObject(id: ToolId): string {
77
+ return `tool:${id}`;
78
+ }
79
+
80
+ export function guardrailObject(id: GuardrailId): string {
81
+ return `guardrail:${id}`;
82
+ }
83
+
84
+ export function evalSuiteObject(id: EvalSuiteId): string {
85
+ return `eval_suite:${id}`;
86
+ }
87
+
88
+ export function secretObject(id: SecretId): string {
89
+ return `secret:${id}`;
90
+ }
91
+
92
+ export function envObject(id: EnvId): string {
93
+ return `env:${id}`;
94
+ }
95
+
96
+ export function mcpEndpointObject(id: McpEndpointId): string {
97
+ return `mcp_endpoint:${id}`;
98
+ }
99
+
100
+ export function runObject(id: RunId): string {
101
+ return `run:${id}`;
102
+ }
103
+
104
+ // ============================================================
105
+ // SCOPE HELPER — for polymorphic config entities
106
+ // ============================================================
107
+
108
+ export type Scope =
109
+ | { readonly kind: 'tenant'; readonly tenantId: TenantId }
110
+ | { readonly kind: 'org'; readonly tenantId: TenantId; readonly orgId: OrgId }
111
+ | { readonly kind: 'project'; readonly tenantId: TenantId; readonly projectId: ProjectId };
112
+
113
+ export function scopeSubject(scope: Scope): string {
114
+ switch (scope.kind) {
115
+ case 'tenant':
116
+ return tenantObject(scope.tenantId);
117
+ case 'org':
118
+ return orgObject(scope.orgId);
119
+ case 'project':
120
+ return projectObject(scope.projectId);
121
+ }
122
+ }
123
+
124
+ // ============================================================
125
+ // ENTITY REFS — typed carriers passed to the tuple generators
126
+ // ============================================================
127
+
128
+ export type EntityRef =
129
+ // Structural
130
+ | {
131
+ readonly kind: 'org';
132
+ readonly id: OrgId;
133
+ readonly tenantId: TenantId;
134
+ }
135
+ | {
136
+ readonly kind: 'project';
137
+ readonly id: ProjectId;
138
+ readonly tenantId: TenantId;
139
+ readonly orgId?: OrgId;
140
+ }
141
+ | {
142
+ readonly kind: 'team';
143
+ readonly id: TeamId;
144
+ readonly tenantId: TenantId;
145
+ readonly orgId?: OrgId;
146
+ }
147
+ // Content (project-scoped)
148
+ | {
149
+ readonly kind: 'agent';
150
+ readonly id: AgentId;
151
+ readonly tenantId: TenantId;
152
+ readonly projectId: ProjectId;
153
+ }
154
+ | {
155
+ readonly kind: 'flow';
156
+ readonly id: FlowId;
157
+ readonly tenantId: TenantId;
158
+ readonly projectId: ProjectId;
159
+ }
160
+ | {
161
+ readonly kind: 'tool';
162
+ readonly id: ToolId;
163
+ readonly tenantId: TenantId;
164
+ readonly projectId: ProjectId;
165
+ }
166
+ | {
167
+ readonly kind: 'guardrail';
168
+ readonly id: GuardrailId;
169
+ readonly tenantId: TenantId;
170
+ readonly projectId: ProjectId;
171
+ }
172
+ | {
173
+ readonly kind: 'eval_suite';
174
+ readonly id: EvalSuiteId;
175
+ readonly tenantId: TenantId;
176
+ readonly projectId: ProjectId;
177
+ }
178
+ // Config (polymorphic scope)
179
+ | {
180
+ readonly kind: 'secret';
181
+ readonly id: SecretId;
182
+ readonly tenantId: TenantId;
183
+ readonly scope: Scope;
184
+ }
185
+ | {
186
+ readonly kind: 'env';
187
+ readonly id: EnvId;
188
+ readonly tenantId: TenantId;
189
+ readonly scope: Scope;
190
+ }
191
+ | {
192
+ readonly kind: 'mcp_endpoint';
193
+ readonly id: McpEndpointId;
194
+ readonly tenantId: TenantId;
195
+ readonly scope: Scope;
196
+ }
197
+ // Runtime
198
+ | {
199
+ readonly kind: 'run';
200
+ readonly id: RunId;
201
+ readonly tenantId: TenantId;
202
+ readonly projectId: ProjectId;
203
+ };
204
+
205
+ // ============================================================
206
+ // TUPLE GENERATORS FOR CREATE / DELETE
207
+ // ============================================================
208
+
209
+ export function tuplesForCreate(entity: EntityRef, creatorUserId?: UserId): TupleIntent[] {
210
+ switch (entity.kind) {
211
+ case 'org': {
212
+ const tuples: TupleIntent[] = [
213
+ {
214
+ operation: 'write',
215
+ user: tenantObject(entity.tenantId),
216
+ relation: 'parent',
217
+ object: orgObject(entity.id),
218
+ },
219
+ ];
220
+ if (creatorUserId !== undefined) {
221
+ tuples.push({
222
+ operation: 'write',
223
+ user: userSubject(creatorUserId),
224
+ relation: 'admin',
225
+ object: orgObject(entity.id),
226
+ });
227
+ }
228
+ return tuples;
229
+ }
230
+
231
+ case 'team': {
232
+ const tuples: TupleIntent[] = [
233
+ {
234
+ operation: 'write',
235
+ user:
236
+ entity.orgId !== undefined ? orgObject(entity.orgId) : tenantObject(entity.tenantId),
237
+ relation: 'parent',
238
+ object: teamObject(entity.id),
239
+ },
240
+ ];
241
+ if (creatorUserId !== undefined) {
242
+ tuples.push({
243
+ operation: 'write',
244
+ user: userSubject(creatorUserId),
245
+ relation: 'admin',
246
+ object: teamObject(entity.id),
247
+ });
248
+ }
249
+ return tuples;
250
+ }
251
+
252
+ case 'project': {
253
+ const tuples: TupleIntent[] = [
254
+ {
255
+ operation: 'write',
256
+ user:
257
+ entity.orgId !== undefined ? orgObject(entity.orgId) : tenantObject(entity.tenantId),
258
+ relation: 'parent',
259
+ object: projectObject(entity.id),
260
+ },
261
+ ];
262
+ if (creatorUserId !== undefined) {
263
+ tuples.push({
264
+ operation: 'write',
265
+ user: userSubject(creatorUserId),
266
+ relation: 'owner',
267
+ object: projectObject(entity.id),
268
+ });
269
+ }
270
+ return tuples;
271
+ }
272
+
273
+ case 'agent':
274
+ case 'flow':
275
+ case 'tool':
276
+ case 'guardrail':
277
+ case 'eval_suite': {
278
+ const object = contentObject(entity);
279
+ const tuples: TupleIntent[] = [
280
+ {
281
+ operation: 'write',
282
+ user: projectObject(entity.projectId),
283
+ relation: 'parent',
284
+ object,
285
+ },
286
+ ];
287
+ if (creatorUserId !== undefined) {
288
+ tuples.push({
289
+ operation: 'write',
290
+ user: userSubject(creatorUserId),
291
+ relation: 'owner',
292
+ object,
293
+ });
294
+ }
295
+ return tuples;
296
+ }
297
+
298
+ case 'secret':
299
+ case 'env':
300
+ case 'mcp_endpoint': {
301
+ const object = configObject(entity);
302
+ const tuples: TupleIntent[] = [
303
+ {
304
+ operation: 'write',
305
+ user: scopeSubject(entity.scope),
306
+ relation: 'scope',
307
+ object,
308
+ },
309
+ ];
310
+ if (creatorUserId !== undefined) {
311
+ tuples.push({
312
+ operation: 'write',
313
+ user: userSubject(creatorUserId),
314
+ relation: 'admin',
315
+ object,
316
+ });
317
+ }
318
+ return tuples;
319
+ }
320
+
321
+ case 'run':
322
+ // Runs are system-created; no `owner` role (read access comes
323
+ // through the parent project).
324
+ return [
325
+ {
326
+ operation: 'write',
327
+ user: projectObject(entity.projectId),
328
+ relation: 'parent',
329
+ object: runObject(entity.id),
330
+ },
331
+ ];
332
+ }
333
+ }
334
+
335
+ export function tuplesForDelete(entity: EntityRef): TupleIntent[] {
336
+ // Deletes only the parent/scope tuple written at create time. Role
337
+ // and grant tuples added later (membership / grant helpers below)
338
+ // are removed separately by whoever wrote them. Orphan tuples are
339
+ // safe — they only reference objects that no longer exist.
340
+ switch (entity.kind) {
341
+ case 'org':
342
+ return [
343
+ {
344
+ operation: 'delete',
345
+ user: tenantObject(entity.tenantId),
346
+ relation: 'parent',
347
+ object: orgObject(entity.id),
348
+ },
349
+ ];
350
+ case 'team':
351
+ return [
352
+ {
353
+ operation: 'delete',
354
+ user:
355
+ entity.orgId !== undefined ? orgObject(entity.orgId) : tenantObject(entity.tenantId),
356
+ relation: 'parent',
357
+ object: teamObject(entity.id),
358
+ },
359
+ ];
360
+ case 'project':
361
+ return [
362
+ {
363
+ operation: 'delete',
364
+ user:
365
+ entity.orgId !== undefined ? orgObject(entity.orgId) : tenantObject(entity.tenantId),
366
+ relation: 'parent',
367
+ object: projectObject(entity.id),
368
+ },
369
+ ];
370
+ case 'agent':
371
+ case 'flow':
372
+ case 'tool':
373
+ case 'guardrail':
374
+ case 'eval_suite':
375
+ return [
376
+ {
377
+ operation: 'delete',
378
+ user: projectObject(entity.projectId),
379
+ relation: 'parent',
380
+ object: contentObject(entity),
381
+ },
382
+ ];
383
+ case 'secret':
384
+ case 'env':
385
+ case 'mcp_endpoint':
386
+ return [
387
+ {
388
+ operation: 'delete',
389
+ user: scopeSubject(entity.scope),
390
+ relation: 'scope',
391
+ object: configObject(entity),
392
+ },
393
+ ];
394
+ case 'run':
395
+ return [
396
+ {
397
+ operation: 'delete',
398
+ user: projectObject(entity.projectId),
399
+ relation: 'parent',
400
+ object: runObject(entity.id),
401
+ },
402
+ ];
403
+ }
404
+ }
405
+
406
+ // ============================================================
407
+ // MEMBERSHIP / GRANT TUPLES
408
+ // ============================================================
409
+
410
+ export type TeamRole = 'member' | 'admin';
411
+ export type ProjectRole = 'owner' | 'admin' | 'editor' | 'viewer';
412
+
413
+ export function teamMembershipTuple(
414
+ op: 'write' | 'delete',
415
+ teamId: TeamId,
416
+ userId: UserId,
417
+ role: TeamRole,
418
+ ): TupleIntent {
419
+ return {
420
+ operation: op,
421
+ user: userSubject(userId),
422
+ relation: role,
423
+ object: teamObject(teamId),
424
+ };
425
+ }
426
+
427
+ export function projectMembershipTuple(
428
+ op: 'write' | 'delete',
429
+ projectId: ProjectId,
430
+ userId: UserId,
431
+ role: ProjectRole,
432
+ ): TupleIntent {
433
+ return {
434
+ operation: op,
435
+ user: userSubject(userId),
436
+ relation: role,
437
+ object: projectObject(projectId),
438
+ };
439
+ }
440
+
441
+ export function teamProjectGrantTuple(
442
+ op: 'write' | 'delete',
443
+ projectId: ProjectId,
444
+ teamId: TeamId,
445
+ role: Exclude<ProjectRole, 'owner'>,
446
+ ): TupleIntent {
447
+ return {
448
+ operation: op,
449
+ user: teamMemberSubject(teamId),
450
+ relation: role,
451
+ object: projectObject(projectId),
452
+ };
453
+ }
454
+
455
+ // ============================================================
456
+ // MACHINE-SUBJECT TUPLES
457
+ // ============================================================
458
+ //
459
+ // Least-privilege grants admitting `agent:X` OR `service_account:X`
460
+ // as subject on the narrow capability relations of the FGA model:
461
+ //
462
+ // tool.invoker — can invoke this tool
463
+ // flow.executor — can execute this flow
464
+ // eval_suite.executor — can execute this suite
465
+ // agent.caller — can invoke this agent (agent-agent + SA-agent)
466
+ // secret.reader — can read this secret
467
+ // env.reader — can read this env var
468
+ // mcp_endpoint.reader — can talk to this MCP endpoint
469
+ //
470
+
471
+ export type MachineSubject =
472
+ | { readonly kind: 'agent'; readonly id: AgentId | string }
473
+ | { readonly kind: 'service_account'; readonly id: string };
474
+
475
+ export type AgentAccessResource =
476
+ | { readonly type: 'tool'; readonly id: ToolId }
477
+ | { readonly type: 'flow'; readonly id: FlowId }
478
+ | { readonly type: 'eval_suite'; readonly id: EvalSuiteId }
479
+ | { readonly type: 'agent'; readonly id: AgentId }
480
+ | { readonly type: 'secret'; readonly id: SecretId }
481
+ | { readonly type: 'env'; readonly id: EnvId }
482
+ | { readonly type: 'mcp_endpoint'; readonly id: McpEndpointId };
483
+
484
+ function machineSubjectString(subject: MachineSubject): string {
485
+ return `${subject.kind}:${subject.id}`;
486
+ }
487
+
488
+ /**
489
+ * Generic machine-subject grant. Works for both `agent` and
490
+ * `service_account` — the capability relations admit both.
491
+ */
492
+ export function machineAccessTuple(
493
+ op: 'write' | 'delete',
494
+ subject: MachineSubject,
495
+ resource: AgentAccessResource,
496
+ ): TupleIntent {
497
+ const subjectString = machineSubjectString(subject);
498
+ switch (resource.type) {
499
+ case 'tool':
500
+ return {
501
+ operation: op,
502
+ user: subjectString,
503
+ relation: 'invoker',
504
+ object: toolObject(resource.id),
505
+ };
506
+ case 'flow':
507
+ return {
508
+ operation: op,
509
+ user: subjectString,
510
+ relation: 'executor',
511
+ object: flowObject(resource.id),
512
+ };
513
+ case 'eval_suite':
514
+ return {
515
+ operation: op,
516
+ user: subjectString,
517
+ relation: 'executor',
518
+ object: evalSuiteObject(resource.id),
519
+ };
520
+ case 'agent':
521
+ return {
522
+ operation: op,
523
+ user: subjectString,
524
+ relation: 'caller',
525
+ object: agentObject(resource.id),
526
+ };
527
+ case 'secret':
528
+ return {
529
+ operation: op,
530
+ user: subjectString,
531
+ relation: 'reader',
532
+ object: secretObject(resource.id),
533
+ };
534
+ case 'env':
535
+ return {
536
+ operation: op,
537
+ user: subjectString,
538
+ relation: 'reader',
539
+ object: envObject(resource.id),
540
+ };
541
+ case 'mcp_endpoint':
542
+ return {
543
+ operation: op,
544
+ user: subjectString,
545
+ relation: 'reader',
546
+ object: mcpEndpointObject(resource.id),
547
+ };
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Agent-subject shorthand for
553
+ * `machineAccessTuple(op, { kind: 'agent', id: agentId }, resource)`.
554
+ */
555
+ export function agentAccessTuple(
556
+ op: 'write' | 'delete',
557
+ agentId: AgentId,
558
+ resource: AgentAccessResource,
559
+ ): TupleIntent {
560
+ return machineAccessTuple(op, { kind: 'agent', id: agentId }, resource);
561
+ }
562
+
563
+ // ============================================================
564
+ // INTERNAL HELPERS
565
+ // ============================================================
566
+
567
+ function contentObject(
568
+ entity: Extract<EntityRef, { kind: 'agent' | 'flow' | 'tool' | 'guardrail' | 'eval_suite' }>,
569
+ ): string {
570
+ switch (entity.kind) {
571
+ case 'agent':
572
+ return agentObject(entity.id);
573
+ case 'flow':
574
+ return flowObject(entity.id);
575
+ case 'tool':
576
+ return toolObject(entity.id);
577
+ case 'guardrail':
578
+ return guardrailObject(entity.id);
579
+ case 'eval_suite':
580
+ return evalSuiteObject(entity.id);
581
+ }
582
+ }
583
+
584
+ function configObject(
585
+ entity: Extract<EntityRef, { kind: 'secret' | 'env' | 'mcp_endpoint' }>,
586
+ ): string {
587
+ switch (entity.kind) {
588
+ case 'secret':
589
+ return secretObject(entity.id);
590
+ case 'env':
591
+ return envObject(entity.id);
592
+ case 'mcp_endpoint':
593
+ return mcpEndpointObject(entity.id);
594
+ }
595
+ }
package/src/types.ts ADDED
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ // FGA authorization primitives — public shape.
5
+ //
6
+ // Owns:
7
+ // - Object type enum (must match the `type X` declarations of the
8
+ // authorization model the deployment's FGA store runs)
9
+ // - Action verb enum (application-facing vocabulary for PEP callers)
10
+ // - Action → computed-relation mapping (PEP → FGA check translation)
11
+ // - Object → allowed-actions catalog (guards against category errors)
12
+ //
13
+ // Consumers:
14
+ // - Principal construction (`principal.ts`) builds {actor, onBehalfOf?}
15
+ // - PEPs check `ACTION_TO_RELATION[action]` on a resource through an
16
+ // `AuthzCheckBinding` (`check.ts`)
17
+ // - Policy executors reuse ObjectType for policy matching
18
+
19
+ export const OBJECT_TYPES = [
20
+ // Structural / boundary
21
+ 'tenant',
22
+ 'org',
23
+ 'team',
24
+ 'project',
25
+ 'user',
26
+ // Content (project-scoped, user-authored)
27
+ 'agent',
28
+ 'flow',
29
+ 'tool',
30
+ 'guardrail',
31
+ 'eval_suite',
32
+ 'trigger',
33
+ 'conversation',
34
+ // Config (polymorphic scope)
35
+ 'secret',
36
+ 'env',
37
+ 'mcp_endpoint',
38
+ // Runtime
39
+ 'run',
40
+ ] as const;
41
+
42
+ export type ObjectType = (typeof OBJECT_TYPES)[number];
43
+
44
+ export const ACTIONS = [
45
+ 'read',
46
+ 'write',
47
+ 'delete',
48
+ 'admin',
49
+ 'execute',
50
+ 'invoke',
51
+ 'publish',
52
+ 'rotate',
53
+ 'fire',
54
+ 'cancel',
55
+ ] as const;
56
+
57
+ export type Action = (typeof ACTIONS)[number];
58
+
59
+ export const ACTION_TO_RELATION: Record<Action, string> = {
60
+ read: 'can_read',
61
+ write: 'can_write',
62
+ delete: 'can_delete',
63
+ admin: 'can_admin',
64
+ execute: 'can_execute',
65
+ invoke: 'can_invoke',
66
+ publish: 'can_publish',
67
+ rotate: 'can_rotate',
68
+ fire: 'can_fire',
69
+ cancel: 'can_cancel',
70
+ };
71
+
72
+ // Which verbs are meaningful for which object types. A PEP uses this to
73
+ // reject e.g. `rotate` on `agent:X` before calling FGA.
74
+ export const OBJECT_ACTIONS: Record<ObjectType, readonly Action[]> = {
75
+ tenant: ['read', 'admin'],
76
+ org: ['read', 'admin'],
77
+ team: ['read', 'admin'],
78
+ project: ['read', 'write', 'admin', 'delete'],
79
+ user: [],
80
+
81
+ agent: ['read', 'write', 'delete', 'admin', 'execute', 'publish'],
82
+ flow: ['read', 'write', 'delete', 'admin', 'execute', 'publish'],
83
+ tool: ['read', 'write', 'delete', 'admin', 'invoke'],
84
+ guardrail: ['read', 'write', 'delete', 'admin'],
85
+ eval_suite: ['read', 'write', 'delete', 'admin', 'execute', 'publish'],
86
+ trigger: ['read', 'write', 'delete', 'admin', 'fire'],
87
+ conversation: ['read', 'write', 'delete', 'admin'],
88
+
89
+ secret: ['read', 'write', 'delete', 'admin', 'rotate'],
90
+ env: ['read', 'write', 'delete', 'admin'],
91
+ mcp_endpoint: ['read', 'write', 'delete', 'admin'],
92
+
93
+ run: ['read', 'cancel', 'delete'],
94
+ };
95
+
96
+ export interface ResourceRef {
97
+ readonly type: ObjectType;
98
+ readonly id: string;
99
+ }
100
+
101
+ export function resourceKey(ref: ResourceRef): string {
102
+ return `${ref.type}:${ref.id}`;
103
+ }