@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/dist/types.js ADDED
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+ // FGA authorization primitives — public shape.
4
+ //
5
+ // Owns:
6
+ // - Object type enum (must match the `type X` declarations of the
7
+ // authorization model the deployment's FGA store runs)
8
+ // - Action verb enum (application-facing vocabulary for PEP callers)
9
+ // - Action → computed-relation mapping (PEP → FGA check translation)
10
+ // - Object → allowed-actions catalog (guards against category errors)
11
+ //
12
+ // Consumers:
13
+ // - Principal construction (`principal.ts`) builds {actor, onBehalfOf?}
14
+ // - PEPs check `ACTION_TO_RELATION[action]` on a resource through an
15
+ // `AuthzCheckBinding` (`check.ts`)
16
+ // - Policy executors reuse ObjectType for policy matching
17
+ export const OBJECT_TYPES = [
18
+ // Structural / boundary
19
+ 'tenant',
20
+ 'org',
21
+ 'team',
22
+ 'project',
23
+ 'user',
24
+ // Content (project-scoped, user-authored)
25
+ 'agent',
26
+ 'flow',
27
+ 'tool',
28
+ 'guardrail',
29
+ 'eval_suite',
30
+ 'trigger',
31
+ 'conversation',
32
+ // Config (polymorphic scope)
33
+ 'secret',
34
+ 'env',
35
+ 'mcp_endpoint',
36
+ // Runtime
37
+ 'run',
38
+ ];
39
+ export const ACTIONS = [
40
+ 'read',
41
+ 'write',
42
+ 'delete',
43
+ 'admin',
44
+ 'execute',
45
+ 'invoke',
46
+ 'publish',
47
+ 'rotate',
48
+ 'fire',
49
+ 'cancel',
50
+ ];
51
+ export const ACTION_TO_RELATION = {
52
+ read: 'can_read',
53
+ write: 'can_write',
54
+ delete: 'can_delete',
55
+ admin: 'can_admin',
56
+ execute: 'can_execute',
57
+ invoke: 'can_invoke',
58
+ publish: 'can_publish',
59
+ rotate: 'can_rotate',
60
+ fire: 'can_fire',
61
+ cancel: 'can_cancel',
62
+ };
63
+ // Which verbs are meaningful for which object types. A PEP uses this to
64
+ // reject e.g. `rotate` on `agent:X` before calling FGA.
65
+ export const OBJECT_ACTIONS = {
66
+ tenant: ['read', 'admin'],
67
+ org: ['read', 'admin'],
68
+ team: ['read', 'admin'],
69
+ project: ['read', 'write', 'admin', 'delete'],
70
+ user: [],
71
+ agent: ['read', 'write', 'delete', 'admin', 'execute', 'publish'],
72
+ flow: ['read', 'write', 'delete', 'admin', 'execute', 'publish'],
73
+ tool: ['read', 'write', 'delete', 'admin', 'invoke'],
74
+ guardrail: ['read', 'write', 'delete', 'admin'],
75
+ eval_suite: ['read', 'write', 'delete', 'admin', 'execute', 'publish'],
76
+ trigger: ['read', 'write', 'delete', 'admin', 'fire'],
77
+ conversation: ['read', 'write', 'delete', 'admin'],
78
+ secret: ['read', 'write', 'delete', 'admin', 'rotate'],
79
+ env: ['read', 'write', 'delete', 'admin'],
80
+ mcp_endpoint: ['read', 'write', 'delete', 'admin'],
81
+ run: ['read', 'cancel', 'delete'],
82
+ };
83
+ export function resourceKey(ref) {
84
+ return `${ref.type}:${ref.id}`;
85
+ }
86
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC;AAEjC,+CAA+C;AAC/C,EAAE;AACF,QAAQ;AACR,oEAAoE;AACpE,2DAA2D;AAC3D,uEAAuE;AACvE,uEAAuE;AACvE,wEAAwE;AACxE,EAAE;AACF,aAAa;AACb,0EAA0E;AAC1E,uEAAuE;AACvE,uCAAuC;AACvC,4DAA4D;AAE5D,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,wBAAwB;IACxB,QAAQ;IACR,KAAK;IACL,MAAM;IACN,SAAS;IACT,MAAM;IACN,0CAA0C;IAC1C,OAAO;IACP,MAAM;IACN,MAAM;IACN,WAAW;IACX,YAAY;IACZ,SAAS;IACT,cAAc;IACd,6BAA6B;IAC7B,QAAQ;IACR,KAAK;IACL,cAAc;IACd,UAAU;IACV,KAAK;CACG,CAAC;AAIX,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,MAAM;IACN,OAAO;IACP,QAAQ;IACR,OAAO;IACP,SAAS;IACT,QAAQ;IACR,SAAS;IACT,QAAQ;IACR,MAAM;IACN,QAAQ;CACA,CAAC;AAIX,MAAM,CAAC,MAAM,kBAAkB,GAA2B;IACxD,IAAI,EAAE,UAAU;IAChB,KAAK,EAAE,WAAW;IAClB,MAAM,EAAE,YAAY;IACpB,KAAK,EAAE,WAAW;IAClB,OAAO,EAAE,aAAa;IACtB,MAAM,EAAE,YAAY;IACpB,OAAO,EAAE,aAAa;IACtB,MAAM,EAAE,YAAY;IACpB,IAAI,EAAE,UAAU;IAChB,MAAM,EAAE,YAAY;CACrB,CAAC;AAEF,wEAAwE;AACxE,wDAAwD;AACxD,MAAM,CAAC,MAAM,cAAc,GAA0C;IACnE,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,GAAG,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC;IACtB,IAAI,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC;IAC7C,IAAI,EAAE,EAAE;IAER,KAAK,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC;IACjE,IAAI,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC;IAChE,IAAI,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC;IACpD,SAAS,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC;IAC/C,UAAU,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC;IACtE,OAAO,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,CAAC;IACrD,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC;IAElD,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC;IACtD,GAAG,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC;IACzC,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC;IAElD,GAAG,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,QAAQ,CAAC;CAClC,CAAC;AAOF,MAAM,UAAU,WAAW,CAAC,GAAgB;IAC1C,OAAO,GAAG,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,EAAE,EAAE,CAAC;AACjC,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,50 @@
1
1
  {
2
2
  "name": "@kindgi/authz",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder so a trusted publisher can be attached. Releases are published from https://github.com/kindgi/kindgi-sdk with provenance; use 0.1.0 or later.",
3
+ "version": "0.1.1",
4
+ "description": "Kindgi™ authorization shape. The public vocabulary and interfaces of Kindgi's authorization stack: Action verbs (read/write/execute/…), ObjectType enum (agent/flow/tool/…), ResourceRef, Principal (who is calling, with delegation and downscoping), the AuthzCheckBinding decision-point interface and its Decision (allow/deny + reason), FGA tuple builders and the TupleEnqueueHook outbox contract, the 403 DenyPayload shape, and reviewer roles. Types and pure functions — no runtime state. The FGA store, the check implementation and the tuple worker are supplied by the deployment.",
5
5
  "license": "Apache-2.0",
6
- "repository": { "type": "git", "url": "git+https://github.com/kindgi/kindgi-sdk.git" }
7
- }
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kindgi/kindgi-sdk.git",
9
+ "directory": "packages/authz"
10
+ },
11
+ "homepage": "https://github.com/kindgi/kindgi-sdk/tree/main/packages/authz#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/kindgi/kindgi-sdk/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "dependencies": {
30
+ "@kindgi/types": "0.1.1"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.0.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.10.5",
41
+ "typescript": "^5.7.3",
42
+ "vitest": "^2.1.8"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.build.json",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run --typecheck --typecheck.only --passWithNoTests",
48
+ "clean": "rm -rf dist *.tsbuildinfo"
49
+ }
50
+ }
package/src/check.ts ADDED
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ //
5
+ // PDP Decision shape + pure `ref` constructor + AuthzCheckBinding
6
+ // interface.
7
+ //
8
+ // This package declares the contract only. An `AuthzCheckBinding`
9
+ // implementation resolves the tenant's FGA store and runs the relation
10
+ // checks; deployments supply one to the enforcement sites.
11
+ //
12
+
13
+ import type { Principal } from './principal.js';
14
+ import type { Action, ObjectType, ResourceRef } from './types.js';
15
+
16
+ export interface Decision {
17
+ readonly allowed: boolean;
18
+ readonly reason: string;
19
+ /**
20
+ * Which link in the (actor, onBehalfOf, scope) chain caused the deny.
21
+ * Undefined when `allowed = true`.
22
+ */
23
+ readonly failing?: 'actor' | 'onBehalfOf' | 'scope' | 'unbootstrapped' | 'invalid-action';
24
+ /**
25
+ * Structured evidence for audit + debugging.
26
+ */
27
+ readonly evidence: {
28
+ readonly action: Action;
29
+ readonly relation: string;
30
+ readonly resource: string;
31
+ readonly actorSubject: string;
32
+ readonly onBehalfOfSubject?: string;
33
+ readonly storeId?: string;
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Convenience constructor for a `ResourceRef`. The type parameter
39
+ * checks `type` against `ObjectType` at the call site; the return type
40
+ * is plain `ResourceRef` (it does not narrow to the given type).
41
+ */
42
+ export function ref<T extends ObjectType>(type: T, id: string): ResourceRef {
43
+ return { type, id };
44
+ }
45
+
46
+ /**
47
+ * Per-call correlation context for `AuthzCheckBinding` methods.
48
+ * Threaded so the emitted audit event carries the request-scoped
49
+ * ids that let operators trace a deny back to the HTTP request or
50
+ * run that produced it.
51
+ */
52
+ export interface AuthzCheckContext {
53
+ /**
54
+ * Optional correlation id — e.g. the API request id from route
55
+ * middleware, or a run-scoped id. Attached to emitted audit events.
56
+ */
57
+ readonly correlationId?: string;
58
+ /**
59
+ * Optional run id — attached to emitted audit events when the check
60
+ * happens inside a run. Distinct from `correlationId` because the
61
+ * run id survives across HTTP requests (streamed responses, replays).
62
+ */
63
+ readonly runId?: string;
64
+ }
65
+
66
+ /**
67
+ * Policy Decision Point binding. Every enforcement site that wants
68
+ * to check authz (for example the @kindgi/api middleware) goes
69
+ * through this contract. The deployment supplies the implementation.
70
+ */
71
+ export interface AuthzCheckBinding {
72
+ /**
73
+ * Applies the principal's downscope filter first (cheap, no FGA
74
+ * round-trip), then calls FGA check for the actor and (if
75
+ * delegated) the `onBehalfOf`. Returns a `Decision` — allowed
76
+ * iff every check passes.
77
+ */
78
+ check(
79
+ principal: Principal,
80
+ action: Action,
81
+ resource: ResourceRef,
82
+ ctx?: AuthzCheckContext,
83
+ ): Promise<Decision>;
84
+
85
+ /**
86
+ * List-filtering primitive. Given a set of resources, returns
87
+ * `Decision`s in the same order — used by `filterByCan` at the
88
+ * API layer to strip rows a caller can't read.
89
+ */
90
+ checkBatch(
91
+ principal: Principal,
92
+ action: Action,
93
+ resources: readonly ResourceRef[],
94
+ ctx?: AuthzCheckContext,
95
+ ): Promise<readonly Decision[]>;
96
+ }
package/src/enqueue.ts ADDED
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ //
5
+ // Tuple-intent shape + enqueue hook contract — the interface between
6
+ // binding write methods and a tuple outbox.
7
+ //
8
+ // Bindings emit `TupleIntent[]` inside their write transactions; the
9
+ // binding implementation persists them atomically alongside the entity
10
+ // mutation, and a worker applies them to the FGA store. The intent
11
+ // shape is an FGA subject/relation/object triple + an optional
12
+ // idempotency key.
13
+ //
14
+
15
+ export interface TupleIntent {
16
+ readonly operation: 'write' | 'delete';
17
+ /** FGA subject, e.g. `user:alice` or `team:eng#member`. */
18
+ readonly user: string;
19
+ /** FGA relation, e.g. `admin` | `parent` | `editor`. */
20
+ readonly relation: string;
21
+ /** FGA object, e.g. `project:foo`. */
22
+ readonly object: string;
23
+ /**
24
+ * Optional per-tenant idempotency key. If set, the outbox rejects a
25
+ * duplicate enqueue (same tenantId + dedupKey) — safe to retry the
26
+ * caller without producing double-tuples.
27
+ */
28
+ readonly dedupKey?: string;
29
+ }
30
+
31
+ /**
32
+ * Callback that produces the tuples for an entity mutation. Bindings
33
+ * call this INSIDE their write transaction with the freshly-inserted
34
+ * (or freshly-resolved, for deletes) entity row's primary key. The
35
+ * binding then enqueues the returned intents in the same transaction.
36
+ *
37
+ * REQUIRED on every content + config binding's write methods. An
38
+ * optional hook would silently split the contract into "writes tuples"
39
+ * vs. "doesn't write tuples" impls — exactly the split-brain that makes
40
+ * PEP unreliable.
41
+ */
42
+ export type TupleEnqueueHook = (entityRowId: string) => readonly TupleIntent[];
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ export * from './types.js';
5
+ export * from './principal.js';
6
+ export * from './pep.js';
7
+ export * from './tuples.js';
8
+ export * from './enqueue.js';
9
+ export * from './check.js';
10
+ export * from './reviewer.js';
package/src/pep.ts ADDED
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ //
5
+ // PEP-side primitives — framework-neutral helpers callable from any
6
+ // HTTP layer (Hono, Express, Fastify, gRPC). The Hono middleware of
7
+ // `@kindgi/api` lives in `packages/api/src/middleware/`.
8
+ //
9
+ // Two exports:
10
+ // - `principalFromToken` — TokenResolution → Principal, honoring
11
+ // service-account vs user semantics
12
+ // - `denyPayload` — canonical structured 403 body shape
13
+ //
14
+
15
+ import type { TenantId, UserId } from '@kindgi/types';
16
+
17
+ import type { Action, ObjectType } from '@kindgi/authz';
18
+
19
+ import { type Principal, serviceAccountPrincipal, userPrincipal } from './principal.js';
20
+
21
+ /**
22
+ * Subset of `TokenResolution` (`@kindgi/api`) that principal construction
23
+ * actually needs. Any bearer/session middleware can adapt its shape
24
+ * to this before calling `principalFromToken`.
25
+ */
26
+ export interface TokenLike {
27
+ readonly tenantId: TenantId;
28
+ readonly userId?: UserId;
29
+ /**
30
+ * Stable id for a capability-only bearer (service account). When
31
+ * the token carries no `userId`, this becomes the actor id.
32
+ * Deployments typically hash the token or use a token-record uuid.
33
+ */
34
+ readonly tokenId?: string;
35
+ }
36
+
37
+ /**
38
+ * Map a resolved token to a Principal.
39
+ *
40
+ * Priority:
41
+ * 1. userId present → userPrincipal (human session)
42
+ * 2. tokenId present → serviceAccountPrincipal (integration key)
43
+ * 3. Neither → throw (unauthenticated shouldn't reach the PEP)
44
+ *
45
+ * Delegation chains (agent-on-behalf-of-user) are constructed by
46
+ * the caller after this returns — the token itself is always a
47
+ * single principal.
48
+ */
49
+ export function principalFromToken(token: TokenLike): Principal {
50
+ if (token.userId !== undefined) {
51
+ return userPrincipal(token.userId, token.tenantId);
52
+ }
53
+ if (token.tokenId !== undefined) {
54
+ return serviceAccountPrincipal(token.tokenId, token.tenantId);
55
+ }
56
+ throw new Error(
57
+ 'principalFromToken: token has neither userId nor tokenId — bearer middleware should have rejected earlier',
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Canonical 403 body shape. Structured so clients can distinguish
63
+ * "you didn't have permission" from "your input was invalid" —
64
+ * different remediation flows. Avoids leaking full evidence (that
65
+ * belongs in the audit trail); reason is a human-readable summary
66
+ * safe to return.
67
+ */
68
+ export interface DenyPayload {
69
+ readonly code: 'permission-denied';
70
+ readonly action: Action;
71
+ readonly resource: string;
72
+ readonly reason: string;
73
+ }
74
+
75
+ export function denyPayload(
76
+ action: Action,
77
+ resourceType: ObjectType,
78
+ resourceId: string,
79
+ reason: string,
80
+ ): DenyPayload {
81
+ return {
82
+ code: 'permission-denied',
83
+ action,
84
+ resource: `${resourceType}:${resourceId}`,
85
+ reason,
86
+ };
87
+ }
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ //
5
+ // Principal — the composite identity passed to every authz check.
6
+ //
7
+ // Composed of:
8
+ // actor: the direct caller (who is REALLY doing this)
9
+ // onBehalfOf: optional delegator (the user this action serves)
10
+ // scope: optional downscoping (OAuth-style permission narrowing)
11
+ //
12
+ // Delegation semantics (as in cloud service-account impersonation and
13
+ // OAuth 2.0 token exchange, RFC 8693): the effective permission is the
14
+ // INTERSECTION of actor + onBehalfOf + scope. `AuthzCheckBinding.check`
15
+ // (`check.ts`) enforces this: it applies the scope filter locally, then
16
+ // checks the actor and (if set) `onBehalfOf` against FGA, and returns
17
+ // `allowed` only when every link permits the action.
18
+ //
19
+ // Actor kinds:
20
+ // - `user` — a human user session
21
+ // - `agent` — a machine agent acting during a run.
22
+ // First-class FGA subject; agents get least-
23
+ // privilege grants (`machineAccessTuple` in
24
+ // `tuples.ts`).
25
+ // - `service_account` — machine identity for capability-only bearer
26
+ // tokens (CI/CD, integrations, deploy bots).
27
+ // First-class FGA subject; grants via
28
+ // `machineAccessTuple`. NOT tenant admin —
29
+ // starts with zero authority.
30
+ // - `system` — framework-initiated action (scheduled jobs,
31
+ // migrations, cleanup). Maps to the
32
+ // `user:system` subject, which the deployment's
33
+ // tenant setup grants tenant admin.
34
+ //
35
+ // Tools are not principals — they are objects that agents invoke.
36
+ //
37
+ // The intersection semantic guards against the confused-deputy
38
+ // problem: an agent must not be able to escalate a user's authority,
39
+ // and a user must not be able to escalate the agent's.
40
+ //
41
+
42
+ import type { TenantId } from '@kindgi/types';
43
+
44
+ import type { Action } from '@kindgi/authz';
45
+
46
+ // ============================================================
47
+ // TYPES
48
+ // ============================================================
49
+
50
+ export type PrincipalKind = 'user' | 'agent' | 'service_account' | 'system';
51
+
52
+ export interface PrincipalRef {
53
+ readonly kind: PrincipalKind;
54
+ readonly id: string;
55
+ readonly tenantId: TenantId;
56
+ }
57
+
58
+ export interface DownscopedPermissions {
59
+ /**
60
+ * Whitelist. When set, only these actions are allowed regardless
61
+ * of what FGA says. Matches OAuth 2.0 scope-narrowing.
62
+ */
63
+ readonly allowedActions?: readonly Action[];
64
+ /**
65
+ * Blacklist. When set, these actions are denied even if FGA allows.
66
+ * Applied AFTER allowedActions.
67
+ */
68
+ readonly deniedActions?: readonly Action[];
69
+ }
70
+
71
+ export interface Principal {
72
+ readonly actor: PrincipalRef;
73
+ readonly onBehalfOf?: PrincipalRef;
74
+ readonly scope?: DownscopedPermissions;
75
+ }
76
+
77
+ // ============================================================
78
+ // CONSTRUCTORS — thin factories for readable call sites
79
+ // ============================================================
80
+
81
+ export function userPrincipal(userId: string, tenantId: TenantId): Principal {
82
+ return { actor: { kind: 'user', id: userId, tenantId } };
83
+ }
84
+
85
+ export function systemPrincipal(tenantId: TenantId): Principal {
86
+ return { actor: { kind: 'system', id: 'system', tenantId } };
87
+ }
88
+
89
+ /**
90
+ * Build a principal from a service-account token. `id` is typically
91
+ * the token id (or a stable identifier derived from it) so grants can
92
+ * be attached to a specific integration.
93
+ */
94
+ export function serviceAccountPrincipal(serviceAccountId: string, tenantId: TenantId): Principal {
95
+ return { actor: { kind: 'service_account', id: serviceAccountId, tenantId } };
96
+ }
97
+
98
+ /**
99
+ * Build a delegated principal: `actor` acting on behalf of `onBehalfOf`.
100
+ * Both must be in the same tenant (checked at construction; mismatches
101
+ * are configuration bugs, not runtime authz denials).
102
+ */
103
+ export function delegate(
104
+ actor: PrincipalRef,
105
+ onBehalfOf: PrincipalRef,
106
+ scope?: DownscopedPermissions,
107
+ ): Principal {
108
+ if (actor.tenantId !== onBehalfOf.tenantId) {
109
+ throw new Error(
110
+ `delegate: cross-tenant delegation not supported (actor.tenantId=${actor.tenantId}, onBehalfOf.tenantId=${onBehalfOf.tenantId})`,
111
+ );
112
+ }
113
+ return {
114
+ actor,
115
+ onBehalfOf,
116
+ ...(scope !== undefined ? { scope } : {}),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Narrow an existing principal's permissions further. Composable with
122
+ * delegation — an agent-on-behalf-of-user can be additionally scoped
123
+ * to read-only for a specific request.
124
+ */
125
+ export function downscope(principal: Principal, scope: DownscopedPermissions): Principal {
126
+ return { ...principal, scope };
127
+ }
128
+
129
+ // ============================================================
130
+ // FGA MAPPING — encapsulated in one place
131
+ // ============================================================
132
+
133
+ /**
134
+ * Return the FGA subject string for a PrincipalRef. This is the ONE
135
+ * place that decides how principal kinds map to FGA subjects.
136
+ *
137
+ * - `user` → `user:${id}` (real user UUID)
138
+ * - `agent` → `agent:${id}`
139
+ * - `service_account` → `service_account:${id}` (machine identity
140
+ * with narrow grants)
141
+ * - `system` → `user:system` (service identity the tenant's
142
+ * setup grants tenant admin; with one FGA store
143
+ * per tenant the string never crosses tenant
144
+ * boundaries)
145
+ */
146
+ export function fgaSubject(ref: PrincipalRef): string {
147
+ switch (ref.kind) {
148
+ case 'user':
149
+ return `user:${ref.id}`;
150
+ case 'agent':
151
+ return `agent:${ref.id}`;
152
+ case 'service_account':
153
+ return `service_account:${ref.id}`;
154
+ case 'system':
155
+ return 'user:system';
156
+ }
157
+ }
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ //
5
+ // Reviewer role vocabulary — the seniority ranks used by HITL approval
6
+ // flows to gate action review. Higher rank subsumes lower: a `senior`
7
+ // reviewer can act on anything requiring `standard`, and `admin` on
8
+ // anything requiring `standard` or `senior`.
9
+ //
10
+ // Role type + rank map live in the public authz vocabulary because
11
+ // reviewer roles are consumed by:
12
+ // - HITL approval bindings
13
+ // - API route validators and `ReviewerBinding` (@kindgi/api)
14
+ // - Client response types (@kindgi/client)
15
+ //
16
+ // Concrete role assignment / storage is caller-plugged via the
17
+ // `ReviewerBinding` interface (declared in @kindgi/api).
18
+ //
19
+
20
+ export type ReviewerRole = 'standard' | 'senior' | 'admin';
21
+
22
+ /**
23
+ * Rank order — index into an ordered scale so callers can express
24
+ * "requires >= senior" without hard-coding string comparisons.
25
+ * Higher number = more senior.
26
+ */
27
+ export const REVIEWER_ROLE_RANK: Readonly<Record<ReviewerRole, number>> = {
28
+ standard: 0,
29
+ senior: 1,
30
+ admin: 2,
31
+ };