@ontrails/permits 0.2.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/src/permit.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { BasePermit } from '@ontrails/core';
2
+
3
+ /** The resolved identity and scopes from a successful authentication. */
4
+ export interface Permit extends BasePermit {
5
+ readonly roles?: readonly string[];
6
+ readonly tenantId?: string;
7
+ readonly metadata?: Readonly<Record<string, unknown>>;
8
+ }
9
+
10
+ /**
11
+ * Type-safe accessor for `ctx.permit` with a downcast to `Permit`.
12
+ *
13
+ * `TrailContext.permit` is typed as `BasePermit` (id + scopes). This accessor
14
+ * returns the full `Permit` when the auth layer has set one. Safe because
15
+ * the auth layer is the only writer and always sets a full `Permit`.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const permit = getPermit(ctx);
20
+ * if (permit) {
21
+ * console.log(permit.roles, permit.tenantId);
22
+ * }
23
+ * ```
24
+ */
25
+ export const getPermit = (ctx: { permit?: BasePermit }): Permit | undefined =>
26
+ ctx.permit === undefined ? undefined : (ctx.permit as Permit);
package/src/rules.ts ADDED
@@ -0,0 +1,189 @@
1
+ import type {
2
+ DiagnosticSeverity,
3
+ RuleDiagnosticBase,
4
+ Trail,
5
+ } from '@ontrails/core';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Diagnostic type
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export type PermitDiagnosticSeverity = DiagnosticSeverity;
12
+
13
+ /** A single governance finding from a permit rule. */
14
+ export interface PermitDiagnostic extends RuleDiagnosticBase {
15
+ readonly trailId: string;
16
+ readonly rule: string;
17
+ readonly severity: PermitDiagnosticSeverity;
18
+ readonly message: string;
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ type AnyTrail = Trail<unknown, unknown, unknown>;
26
+ type Rule = (trails: readonly AnyTrail[]) => readonly PermitDiagnostic[];
27
+
28
+ /** Check whether a trail has any permit declaration (scopes object or 'public'). */
29
+ const hasPermit = (t: AnyTrail): boolean => t.permit !== undefined;
30
+
31
+ /** Extract scopes from a trail's permit declaration, or empty array. */
32
+ const getScopes = (t: AnyTrail): readonly string[] => {
33
+ if (t.permit !== undefined && t.permit !== 'public') {
34
+ return t.permit.scopes;
35
+ }
36
+ return [];
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Rule: destroyWithoutPermit
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Destructive trails without a real permit declaration are a governance failure.
45
+ *
46
+ * Reports an error for every trail with `intent: 'destroy'` that has no
47
+ * `permit` field or explicitly opts out with `permit: 'public'`.
48
+ */
49
+ export const destroyWithoutPermit: Rule = (trails) =>
50
+ trails
51
+ .filter(
52
+ (t) => t.intent === 'destroy' && (!hasPermit(t) || t.permit === 'public')
53
+ )
54
+ .map((t) => ({
55
+ message: `Trail "${t.id}" has intent 'destroy' but no permit declaration`,
56
+ rule: 'destroyWithoutPermit',
57
+ severity: 'error' as const,
58
+ trailId: t.id,
59
+ }));
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Rule: writeWithoutPermit
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Write trails without a permit declaration get a warning.
67
+ *
68
+ * Trails with `intent: 'write'` (or no intent, which defaults to write) that
69
+ * lack a permit are flagged unless `permit: 'public'` is explicitly set.
70
+ */
71
+ export const writeWithoutPermit: Rule = (trails) =>
72
+ trails
73
+ .filter(
74
+ (t) => (t.intent === 'write' || t.intent === undefined) && !hasPermit(t)
75
+ )
76
+ .map((t) => ({
77
+ message: `Trail "${t.id}" has write intent but no permit declaration`,
78
+ rule: 'writeWithoutPermit',
79
+ severity: 'warn' as const,
80
+ trailId: t.id,
81
+ }));
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Rule: scopeNamingConsistency
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /** Returns true when a scope follows the `entity:action` convention. */
88
+ const isValidScopeFormat = (scope: string): boolean => {
89
+ const parts = scope.split(':');
90
+ return (
91
+ parts.length === 2 &&
92
+ (parts[0]?.length ?? 0) > 0 &&
93
+ (parts[1]?.length ?? 0) > 0
94
+ );
95
+ };
96
+
97
+ /**
98
+ * Warns for scopes that don't follow the `entity:action` convention.
99
+ *
100
+ * A valid scope contains exactly one colon separating a non-empty entity
101
+ * and a non-empty action.
102
+ */
103
+ export const scopeNamingConsistency: Rule = (trails) =>
104
+ trails.flatMap((t) =>
105
+ getScopes(t)
106
+ .filter((scope) => !isValidScopeFormat(scope))
107
+ .map((scope) => ({
108
+ message: `Scope "${scope}" on trail "${t.id}" does not follow entity:action convention`,
109
+ rule: 'scopeNamingConsistency',
110
+ severity: 'warn' as const,
111
+ trailId: t.id,
112
+ }))
113
+ );
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Rule: orphanScopeDetection
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Build a map of scope -> set of trail IDs that declare it. */
120
+ const buildScopeMap = (
121
+ trails: readonly AnyTrail[]
122
+ ): ReadonlyMap<string, ReadonlySet<string>> => {
123
+ const map = new Map<string, Set<string>>();
124
+ for (const t of trails) {
125
+ for (const scope of getScopes(t)) {
126
+ const existing = map.get(scope);
127
+ if (existing) {
128
+ existing.add(t.id);
129
+ } else {
130
+ map.set(scope, new Set([t.id]));
131
+ }
132
+ }
133
+ }
134
+ return map;
135
+ };
136
+
137
+ /** Filter trails that have a scoped (non-public) permit declaration. */
138
+ const trailsWithScopedPermit = (
139
+ trails: readonly AnyTrail[]
140
+ ): readonly AnyTrail[] =>
141
+ trails.filter((t) => t.permit !== undefined && t.permit !== 'public');
142
+
143
+ /** Convert orphan scope map entries into diagnostics. */
144
+ const orphanDiagnostics = (
145
+ scopeMap: ReadonlyMap<string, ReadonlySet<string>>
146
+ ): readonly PermitDiagnostic[] =>
147
+ [...scopeMap.entries()]
148
+ .filter(([, ids]) => ids.size === 1)
149
+ .map(([scope, ids]) => ({
150
+ message: `Scope "${scope}" appears only on trail "${[...ids][0]}" — possible typo`,
151
+ rule: 'orphanScopeDetection',
152
+ severity: 'warn' as const,
153
+ trailId: [...ids][0] ?? '',
154
+ }));
155
+
156
+ /**
157
+ * Warns for scopes that appear in only one trail's permit.
158
+ *
159
+ * Catches typos like `user:wirte` by surfacing scopes not shared with any
160
+ * other trail. Only runs when at least 2 trails have permit declarations.
161
+ */
162
+ export const orphanScopeDetection: Rule = (trails) => {
163
+ const scoped = trailsWithScopedPermit(trails);
164
+ if (scoped.length < 2) {
165
+ return [];
166
+ }
167
+ return orphanDiagnostics(buildScopeMap(scoped));
168
+ };
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Top-level validator
172
+ // ---------------------------------------------------------------------------
173
+
174
+ const allRules: readonly Rule[] = [
175
+ destroyWithoutPermit,
176
+ writeWithoutPermit,
177
+ scopeNamingConsistency,
178
+ orphanScopeDetection,
179
+ ];
180
+
181
+ /**
182
+ * Run all permit governance rules against a set of trails.
183
+ *
184
+ * Returns a flat array of diagnostics from every rule. An empty array
185
+ * means the topo passes all permit governance checks.
186
+ */
187
+ export const validatePermits = (
188
+ trails: readonly Trail<unknown, unknown, unknown>[]
189
+ ): readonly PermitDiagnostic[] => allRules.flatMap((rule) => rule(trails));
package/src/testing.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { PermitRequirement } from '@ontrails/core';
2
+
3
+ import type { Permit } from './permit.js';
4
+
5
+ /**
6
+ * Create a synthetic test permit with exactly the declared scopes.
7
+ * No admin permit, no wildcard — tests get only what the trail declares.
8
+ */
9
+ export const createTestPermit = (options?: {
10
+ readonly id?: string;
11
+ readonly scopes?: readonly string[];
12
+ readonly roles?: readonly string[];
13
+ readonly tenantId?: string;
14
+ }): Permit => ({
15
+ id:
16
+ options?.id ??
17
+ `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
18
+ scopes: [...(options?.scopes ?? [])],
19
+ ...(options?.roles === undefined ? {} : { roles: [...options.roles] }),
20
+ ...(options?.tenantId === undefined ? {} : { tenantId: options.tenantId }),
21
+ });
22
+
23
+ /**
24
+ * Create a test permit matching a trail's permit requirement.
25
+ * Extracts scopes from the requirement and creates a permit with exactly those scopes.
26
+ */
27
+ export const createPermitForTrail = (trail: {
28
+ readonly permit?: PermitRequirement | undefined;
29
+ }): Permit | undefined => {
30
+ if (!trail.permit || trail.permit === 'public') {
31
+ return undefined;
32
+ }
33
+ return createTestPermit({ scopes: trail.permit.scopes });
34
+ };
@@ -0,0 +1,99 @@
1
+ import { Result, SURFACE_KEY, trail } from '@ontrails/core';
2
+ import type { TrailContext } from '@ontrails/core';
3
+ import { z } from 'zod';
4
+
5
+ import { authResource } from '../auth-resource.js';
6
+ import type { PermitExtractionInput } from '../extraction.js';
7
+ import type { Permit } from '../permit.js';
8
+
9
+ const permitSchema = z.object({
10
+ id: z.string(),
11
+ metadata: z.record(z.string(), z.unknown()).optional(),
12
+ roles: z.array(z.string()).optional(),
13
+ scopes: z.array(z.string()),
14
+ tenantId: z.string().optional(),
15
+ });
16
+ const authErrorCodeSchema = z.enum([
17
+ 'expired_token',
18
+ 'insufficient_scope',
19
+ 'invalid_token',
20
+ 'missing_credentials',
21
+ ]);
22
+
23
+ const toOutputPermit = (permit: Permit) => ({
24
+ ...(permit.metadata === undefined
25
+ ? {}
26
+ : { metadata: { ...permit.metadata } }),
27
+ ...(permit.roles === undefined ? {} : { roles: [...permit.roles] }),
28
+ ...(permit.tenantId === undefined ? {} : { tenantId: permit.tenantId }),
29
+ id: permit.id,
30
+ scopes: [...permit.scopes],
31
+ });
32
+
33
+ const isSurfaceName = (
34
+ value: unknown
35
+ ): value is PermitExtractionInput['surface'] =>
36
+ value === 'http' || value === 'mcp' || value === 'cli';
37
+
38
+ const getSurface = (ctx: TrailContext): PermitExtractionInput['surface'] => {
39
+ const surface = ctx.extensions?.[SURFACE_KEY];
40
+ return isSurfaceName(surface) ? surface : 'http';
41
+ };
42
+
43
+ /**
44
+ * Infrastructure trail that verifies a bearer token and returns the resolved permit.
45
+ *
46
+ * Reads the auth adapter from `authResource` — the adapter is configured
47
+ * at bootstrap (e.g. JWT with HMAC secret). The mock adapter always
48
+ * succeeds with a null permit, so `testAll(app)` works without configuration.
49
+ */
50
+ export const authVerify = trail('auth.verify', {
51
+ examples: [
52
+ {
53
+ input: { token: 'test-token' },
54
+ name: 'Verify a token',
55
+ },
56
+ ],
57
+ implementation: async (input, ctx) => {
58
+ const adapter = authResource.from(ctx);
59
+ const result = await adapter.authenticate({
60
+ bearerToken: input.token,
61
+ requestId: ctx.requestId,
62
+ surface: getSurface(ctx),
63
+ });
64
+
65
+ if (result.isErr()) {
66
+ return Result.ok({
67
+ error: result.error.message,
68
+ errorCode: result.error.code,
69
+ valid: false,
70
+ });
71
+ }
72
+
73
+ const permit = result.value;
74
+ if (!permit) {
75
+ return Result.ok({
76
+ error: 'No credentials',
77
+ errorCode: 'missing_credentials',
78
+ valid: false,
79
+ });
80
+ }
81
+
82
+ return Result.ok({
83
+ permit: toOutputPermit(permit),
84
+ valid: true,
85
+ });
86
+ },
87
+ input: z.object({
88
+ token: z.string().min(1).describe('Bearer token to verify'),
89
+ }),
90
+ intent: 'read',
91
+ meta: { category: 'infrastructure' },
92
+ output: z.object({
93
+ error: z.string().optional(),
94
+ errorCode: authErrorCodeSchema.optional(),
95
+ permit: permitSchema.optional(),
96
+ valid: z.boolean(),
97
+ }),
98
+ resources: [authResource],
99
+ });