@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/CHANGELOG.md +416 -0
- package/README.md +182 -0
- package/package.json +35 -0
- package/src/adapters/adapter.ts +41 -0
- package/src/adapters/jwt.ts +359 -0
- package/src/auth-resource.ts +90 -0
- package/src/boundary.ts +200 -0
- package/src/errors.ts +1 -0
- package/src/extraction.ts +25 -0
- package/src/index.ts +33 -0
- package/src/permit.ts +26 -0
- package/src/rules.ts +189 -0
- package/src/testing.ts +34 -0
- package/src/trails/auth-verify.ts +99 -0
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
|
+
});
|