@c9up/warden 0.1.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/LICENSE +21 -0
- package/README.md +35 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +81 -0
- package/scripts/copy-napi.mjs +67 -0
- package/src/AuthManager.ts +228 -0
- package/src/AuthRateLimiter.ts +105 -0
- package/src/Guard.ts +68 -0
- package/src/RefreshTokenStore.ts +67 -0
- package/src/TokenBlacklist.ts +58 -0
- package/src/WardenProvider.ts +84 -0
- package/src/bouncer/AuthorizationResponse.ts +38 -0
- package/src/bouncer/BasePolicy.ts +66 -0
- package/src/bouncer/Bouncer.ts +236 -0
- package/src/bouncer/PolicyAuthorizer.ts +125 -0
- package/src/bouncer/decorators.ts +44 -0
- package/src/bouncer/evaluate.ts +104 -0
- package/src/bouncer/policyContext.ts +53 -0
- package/src/bouncer/types.ts +67 -0
- package/src/config.ts +48 -0
- package/src/configure.ts +59 -0
- package/src/errors.ts +25 -0
- package/src/firstcontact/FirstContactManager.ts +54 -0
- package/src/firstcontact/drivers/GitHubDriver.ts +78 -0
- package/src/firstcontact/drivers/GoogleDriver.ts +81 -0
- package/src/firstcontact/types.ts +38 -0
- package/src/index.ts +77 -0
- package/src/middleware.ts +283 -0
- package/src/native.ts +53 -0
- package/src/rights/MemoryRightsStore.ts +92 -0
- package/src/rights/RightsResolver.ts +84 -0
- package/src/rights/types.ts +58 -0
- package/src/services/main.ts +40 -0
- package/src/standalone.ts +128 -0
- package/src/strategies/ApiKeyStrategy.ts +54 -0
- package/src/strategies/JwtStrategy.ts +217 -0
- package/src/strategies/SessionStrategy.ts +105 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warden auth middleware — resolves the AuthManager from the Ream IoC container
|
|
3
|
+
* and authenticates the request using the configured strategy.
|
|
4
|
+
*
|
|
5
|
+
* Same pattern as `@c9up/blackhole/middleware` and AdonisJS `@adonisjs/auth`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // start/kernel.ts
|
|
9
|
+
* router.use([() => import('@c9up/warden/middleware')])
|
|
10
|
+
*
|
|
11
|
+
* // On protected routes
|
|
12
|
+
* import { Guard } from '@c9up/warden'
|
|
13
|
+
* class OrderController {
|
|
14
|
+
* @Guard('jwt')
|
|
15
|
+
* async index(ctx) { ... }
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
AuthManager,
|
|
21
|
+
type AuthResult,
|
|
22
|
+
type AuthStrategy,
|
|
23
|
+
sanitizePayload,
|
|
24
|
+
} from "./AuthManager.js";
|
|
25
|
+
import {
|
|
26
|
+
getGuardMetadata,
|
|
27
|
+
getPermissionMetadata,
|
|
28
|
+
getRoleMetadata,
|
|
29
|
+
} from "./Guard.js";
|
|
30
|
+
import type { SessionStore } from "./strategies/SessionStrategy.js";
|
|
31
|
+
|
|
32
|
+
interface StrategyWithContext extends AuthStrategy {
|
|
33
|
+
verifyWithContext(token: string, ctx: unknown): Promise<AuthResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasVerifyWithContext(
|
|
37
|
+
strategy: AuthStrategy,
|
|
38
|
+
): strategy is StrategyWithContext {
|
|
39
|
+
return (
|
|
40
|
+
typeof (strategy as StrategyWithContext).verifyWithContext === "function"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WardenContext {
|
|
45
|
+
request: {
|
|
46
|
+
method: string;
|
|
47
|
+
url: string;
|
|
48
|
+
headers: Record<string, string>;
|
|
49
|
+
};
|
|
50
|
+
response: {
|
|
51
|
+
status: (code: number) => void;
|
|
52
|
+
json: (data: unknown) => void;
|
|
53
|
+
};
|
|
54
|
+
container: {
|
|
55
|
+
resolve: (
|
|
56
|
+
key: string | symbol | (abstract new (...args: never[]) => unknown),
|
|
57
|
+
) => unknown;
|
|
58
|
+
};
|
|
59
|
+
/** Session store — set by a session middleware upstream. */
|
|
60
|
+
session?: SessionStore;
|
|
61
|
+
/** Set by the middleware after successful auth. */
|
|
62
|
+
auth?: AuthResult;
|
|
63
|
+
/** The route handler metadata (decorators). */
|
|
64
|
+
route?: {
|
|
65
|
+
controller?: object;
|
|
66
|
+
action?: string | symbol;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type WardenNext = () => Promise<void> | void;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ream auth middleware — resolves AuthManager from the container,
|
|
74
|
+
* reads `@Guard`/`@Permission`/`@Role` metadata from the route handler,
|
|
75
|
+
* and authenticates + authorizes the request.
|
|
76
|
+
*/
|
|
77
|
+
export async function wardenMiddleware(ctx: WardenContext, next: WardenNext) {
|
|
78
|
+
const auth = ctx.container.resolve(AuthManager) as AuthManager;
|
|
79
|
+
|
|
80
|
+
// Read guard metadata from the route handler (if declared via decorators).
|
|
81
|
+
const controller = ctx.route?.controller;
|
|
82
|
+
const action = ctx.route?.action;
|
|
83
|
+
const strategies =
|
|
84
|
+
controller && action ? getGuardMetadata(controller, action) : [];
|
|
85
|
+
|
|
86
|
+
// No guard on this route — pass through (public endpoint).
|
|
87
|
+
if (strategies.length === 0) {
|
|
88
|
+
await next();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Extract credentials from multiple sources:
|
|
93
|
+
// 1. Authorization: Bearer <jwt> (JWT strategy)
|
|
94
|
+
// 2. API key header — reads the header name from the ApiKeyStrategy config
|
|
95
|
+
// (defaults to 'x-api-key' if no ApiKeyStrategy is registered)
|
|
96
|
+
// 3. Cookie/session (Session strategy — handled by the strategy itself via context)
|
|
97
|
+
const authHeader = ctx.request.headers.authorization ?? "";
|
|
98
|
+
const bearerToken = authHeader.startsWith("Bearer ")
|
|
99
|
+
? authHeader.slice(7)
|
|
100
|
+
: "";
|
|
101
|
+
const apiKeyHeader = (() => {
|
|
102
|
+
try {
|
|
103
|
+
const s = auth.getStrategy("api-key");
|
|
104
|
+
return (s as { headerName?: string }).headerName ?? "x-api-key";
|
|
105
|
+
} catch {
|
|
106
|
+
return "x-api-key";
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
// HTTP header names are case-insensitive. Node + every Ream-side
|
|
110
|
+
// runtime lowercases incoming header keys, so an `ApiKeyStrategy`
|
|
111
|
+
// declared with `headerName: "X-Custom-Key"` would never match the
|
|
112
|
+
// incoming `x-custom-key` key without this normalisation — auth
|
|
113
|
+
// would silently fail even with the right header on the wire.
|
|
114
|
+
const apiKey = ctx.request.headers[apiKeyHeader.toLowerCase()] ?? "";
|
|
115
|
+
|
|
116
|
+
const hasSessionStrategy = strategies.some((s) => s === "session");
|
|
117
|
+
if (!bearerToken && !apiKey && !hasSessionStrategy) {
|
|
118
|
+
ctx.response.status(401);
|
|
119
|
+
ctx.response.json({
|
|
120
|
+
error: {
|
|
121
|
+
code: "UNAUTHORIZED",
|
|
122
|
+
message: "Missing authentication token (Bearer or x-api-key)",
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Try each declared strategy. If every attempted strategy crashed AND none
|
|
129
|
+
// succeeded, the caller returns 500 instead of 401 — a server-side incident
|
|
130
|
+
// must stay observably distinct from a credential rejection.
|
|
131
|
+
const { result, attemptCount, crashCount } = await tryAuthenticate(
|
|
132
|
+
auth,
|
|
133
|
+
strategies,
|
|
134
|
+
{ bearerToken, apiKey, session: ctx.session, hasSessionStrategy },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!result?.authenticated || !result.user) {
|
|
138
|
+
if (attemptCount > 0 && crashCount === attemptCount) {
|
|
139
|
+
ctx.response.status(500);
|
|
140
|
+
ctx.response.json({
|
|
141
|
+
error: {
|
|
142
|
+
code: "AUTH_STRATEGY_ERROR",
|
|
143
|
+
message:
|
|
144
|
+
"Authentication unavailable — one or more strategies failed. Check server logs.",
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
ctx.response.status(401);
|
|
150
|
+
ctx.response.json({
|
|
151
|
+
error: {
|
|
152
|
+
code: "UNAUTHORIZED",
|
|
153
|
+
message: result?.error ?? "Authentication failed",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const user = result.user;
|
|
160
|
+
|
|
161
|
+
// Permission + role checks are independent AND gates: the user must satisfy
|
|
162
|
+
// ALL required permissions AND ALL required roles. Having a role does NOT
|
|
163
|
+
// bypass permission checks. This is the strictest model — callers who want
|
|
164
|
+
// role-OR-permission semantics should use a custom guard instead.
|
|
165
|
+
if (controller && action) {
|
|
166
|
+
const denied = await checkAuthorization(auth, user, controller, action);
|
|
167
|
+
if (denied) {
|
|
168
|
+
ctx.response.status(403);
|
|
169
|
+
ctx.response.json({ error: { code: "FORBIDDEN", message: denied } });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Auth successful — attach to context and continue.
|
|
175
|
+
ctx.auth = result;
|
|
176
|
+
await next();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Try each declared strategy in order — session strategies via
|
|
181
|
+
* `verifyWithContext()`, others via `verify(token)` with native-first credential
|
|
182
|
+
* fallback. Distinguishes crashes (strategy threw / `strategyCrash`) from
|
|
183
|
+
* credential rejections so the caller can return 500 vs 401.
|
|
184
|
+
*/
|
|
185
|
+
async function tryAuthenticate(
|
|
186
|
+
auth: AuthManager,
|
|
187
|
+
strategies: string[],
|
|
188
|
+
creds: {
|
|
189
|
+
bearerToken: string;
|
|
190
|
+
apiKey: string;
|
|
191
|
+
session: SessionStore | undefined;
|
|
192
|
+
hasSessionStrategy: boolean;
|
|
193
|
+
},
|
|
194
|
+
): Promise<{
|
|
195
|
+
result: AuthResult | null;
|
|
196
|
+
attemptCount: number;
|
|
197
|
+
crashCount: number;
|
|
198
|
+
}> {
|
|
199
|
+
const { bearerToken, apiKey, session } = creds;
|
|
200
|
+
let result: AuthResult | null = null;
|
|
201
|
+
let attemptCount = 0;
|
|
202
|
+
let crashCount = 0;
|
|
203
|
+
for (const strategyName of strategies) {
|
|
204
|
+
try {
|
|
205
|
+
let r: AuthResult;
|
|
206
|
+
if (strategyName === "session") {
|
|
207
|
+
const strategy = auth.getStrategy(strategyName);
|
|
208
|
+
const verifyWithContext =
|
|
209
|
+
strategy && hasVerifyWithContext(strategy)
|
|
210
|
+
? strategy.verifyWithContext
|
|
211
|
+
: undefined;
|
|
212
|
+
if (verifyWithContext) {
|
|
213
|
+
attemptCount++;
|
|
214
|
+
r = await verifyWithContext.call(strategy, "", { session });
|
|
215
|
+
// The session path bypasses AuthManager.verify(), so apply the
|
|
216
|
+
// same prototype-pollution guard JWT / api-key users get there.
|
|
217
|
+
if (r.user) sanitizePayload(r.user);
|
|
218
|
+
} else {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
// Native-first credential, other transport as fallback so a
|
|
223
|
+
// single-credential client still authenticates (and an invalid
|
|
224
|
+
// Bearer no longer masks a valid API key for the api-key strategy).
|
|
225
|
+
const credential =
|
|
226
|
+
strategyName === "api-key"
|
|
227
|
+
? apiKey || bearerToken
|
|
228
|
+
: bearerToken || apiKey;
|
|
229
|
+
if (!credential) continue;
|
|
230
|
+
attemptCount++;
|
|
231
|
+
r = await auth.verify(credential, strategyName);
|
|
232
|
+
}
|
|
233
|
+
if (r.authenticated) {
|
|
234
|
+
result = r;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
if (r.strategyCrash === true) {
|
|
238
|
+
crashCount++;
|
|
239
|
+
console.error(
|
|
240
|
+
`[warden] strategy '${strategyName}' threw during verify(): ${r.error ?? "unknown error"}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
// AuthManager rethrows structured WardenError sentinels — treat these
|
|
245
|
+
// as crashes too (config errors, SessionStrategy.USE_LOGIN, etc.).
|
|
246
|
+
crashCount++;
|
|
247
|
+
console.error(
|
|
248
|
+
`[warden] strategy '${strategyName}' threw during verify():`,
|
|
249
|
+
err,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { result, attemptCount, crashCount };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Gate the authenticated user against the route's @Permission / @Role decorators.
|
|
258
|
+
* Both are independent AND gates resolved ONCE against the same unified
|
|
259
|
+
* EffectivePermissions set (Epic 56, D7). Returns the FORBIDDEN message, or null
|
|
260
|
+
* when authorised (and zero-cost when neither decorator is present).
|
|
261
|
+
*/
|
|
262
|
+
async function checkAuthorization(
|
|
263
|
+
auth: AuthManager,
|
|
264
|
+
user: NonNullable<AuthResult["user"]>,
|
|
265
|
+
controller: object,
|
|
266
|
+
action: string | symbol,
|
|
267
|
+
): Promise<string | null> {
|
|
268
|
+
const requiredPermissions = getPermissionMetadata(controller, action);
|
|
269
|
+
const requiredRoles = getRoleMetadata(controller, action);
|
|
270
|
+
if (requiredPermissions.length === 0 && requiredRoles.length === 0) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const effective = await auth.resolvePermissions(user, "global");
|
|
274
|
+
if (requiredPermissions.length > 0) {
|
|
275
|
+
const missing = requiredPermissions.filter((p) => !effective.has(p));
|
|
276
|
+
if (missing.length > 0) return `Missing permissions: ${missing.join(", ")}`;
|
|
277
|
+
}
|
|
278
|
+
if (requiredRoles.length > 0) {
|
|
279
|
+
const missing = requiredRoles.filter((r) => !effective.roles.has(r));
|
|
280
|
+
if (missing.length > 0) return `Missing roles: ${missing.join(", ")}`;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warden NAPI loader — loads the `warden-engine-napi` binary for Rust-native
|
|
3
|
+
* JWT/HMAC/random operations. There is no TS fallback: when the binary is
|
|
4
|
+
* absent, `nativeWarden()` returns `undefined` and `JwtStrategy` (and any
|
|
5
|
+
* other consumer) throws `WARDEN_NAPI_REQUIRED` at use time. Password
|
|
6
|
+
* hashing is the sole responsibility of `@c9up/sigil` (story 40.1).
|
|
7
|
+
*/
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { arch, platform } from "node:process";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
14
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
const platformMap: Record<string, string> = {
|
|
17
|
+
"linux-x64": "linux-x64-gnu",
|
|
18
|
+
"linux-arm64": "linux-arm64-gnu",
|
|
19
|
+
"darwin-x64": "darwin-x64",
|
|
20
|
+
"darwin-arm64": "darwin-arm64",
|
|
21
|
+
"win32-x64": "win32-x64-msvc",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface NativeWarden {
|
|
25
|
+
jwtSign(payload: string, secret: string): string;
|
|
26
|
+
jwtVerify(token: string, secret: string): string;
|
|
27
|
+
constantTimeEq(a: string, b: string): boolean;
|
|
28
|
+
hmacSign(data: string, secret: string): string;
|
|
29
|
+
hmacVerify(data: string, signature: string, secret: string): boolean;
|
|
30
|
+
randomBytes(len: number): string;
|
|
31
|
+
randomHex(len: number): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let native: NativeWarden | undefined;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const suffix = platformMap[`${platform}-${arch}`];
|
|
38
|
+
if (suffix) {
|
|
39
|
+
native = nodeRequire(join(currentDir, `../index.${suffix}.node`));
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Binary not available — JwtStrategy will throw WARDEN_NAPI_REQUIRED at use time.
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the Rust NAPI engine loaded. */
|
|
46
|
+
export function isNativeAvailable(): boolean {
|
|
47
|
+
return native !== undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Get the native engine. Returns undefined if not loaded — caller must throw. */
|
|
51
|
+
export function nativeWarden(): NativeWarden | undefined {
|
|
52
|
+
return native;
|
|
53
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryRightsStore — the shipped in-memory rights driver.
|
|
3
|
+
*
|
|
4
|
+
* Implements the read-only `RightsStore` contract and adds chainable seeding
|
|
5
|
+
* methods (`defineRole`/`assignRole`/`grant`/`revoke`) for app boot and tests.
|
|
6
|
+
* DB-backed drivers are a documented copy-in adapter, not a hard dependency —
|
|
7
|
+
* this driver keeps Warden's agnostic posture with zero new dependencies.
|
|
8
|
+
*
|
|
9
|
+
* Storage is Map-based, keyed by `scopeKey(scope)` then by role/userId, so
|
|
10
|
+
* `global` and `tenant:X` rights stay isolated (inheritance is the resolver's
|
|
11
|
+
* job, not the store's).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type RightsStore, type Scope, scopeKey } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export class MemoryRightsStore implements RightsStore {
|
|
17
|
+
/** scopeKey → role → permissions */
|
|
18
|
+
private readonly roleDefs = new Map<string, Map<string, Set<string>>>();
|
|
19
|
+
/** scopeKey → userId → roles */
|
|
20
|
+
private readonly userRoleMap = new Map<string, Map<string, Set<string>>>();
|
|
21
|
+
/** scopeKey → userId → directly granted permissions */
|
|
22
|
+
private readonly userGrantMap = new Map<string, Map<string, Set<string>>>();
|
|
23
|
+
|
|
24
|
+
// — read contract —
|
|
25
|
+
|
|
26
|
+
async rolePermissions(
|
|
27
|
+
role: string,
|
|
28
|
+
scope: Scope,
|
|
29
|
+
): Promise<readonly string[]> {
|
|
30
|
+
return snapshot(this.roleDefs.get(scopeKey(scope))?.get(role));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async userRoles(userId: string, scope: Scope): Promise<readonly string[]> {
|
|
34
|
+
return snapshot(this.userRoleMap.get(scopeKey(scope))?.get(userId));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async userGrants(userId: string, scope: Scope): Promise<readonly string[]> {
|
|
38
|
+
return snapshot(this.userGrantMap.get(scopeKey(scope))?.get(userId));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// — seeding (in-memory driver only; not part of RightsStore) —
|
|
42
|
+
|
|
43
|
+
defineRole(
|
|
44
|
+
role: string,
|
|
45
|
+
permissions: readonly string[],
|
|
46
|
+
scope: Scope = "global",
|
|
47
|
+
): this {
|
|
48
|
+
const set = bucket(this.roleDefs, scopeKey(scope), role);
|
|
49
|
+
set.clear();
|
|
50
|
+
for (const perm of permissions) set.add(perm);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
assignRole(userId: string, role: string, scope: Scope = "global"): this {
|
|
55
|
+
bucket(this.userRoleMap, scopeKey(scope), userId).add(role);
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
grant(userId: string, permission: string, scope: Scope = "global"): this {
|
|
60
|
+
bucket(this.userGrantMap, scopeKey(scope), userId).add(permission);
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
revoke(userId: string, permission: string, scope: Scope = "global"): this {
|
|
65
|
+
this.userGrantMap.get(scopeKey(scope))?.get(userId)?.delete(permission);
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Defensive array snapshot — never hands back a live reference to internal state. */
|
|
71
|
+
function snapshot(set: Set<string> | undefined): readonly string[] {
|
|
72
|
+
return set ? [...set] : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Get-or-create the inner `Set` for `outer[key][inner]`. */
|
|
76
|
+
function bucket(
|
|
77
|
+
outer: Map<string, Map<string, Set<string>>>,
|
|
78
|
+
key: string,
|
|
79
|
+
inner: string,
|
|
80
|
+
): Set<string> {
|
|
81
|
+
let mid = outer.get(key);
|
|
82
|
+
if (!mid) {
|
|
83
|
+
mid = new Map<string, Set<string>>();
|
|
84
|
+
outer.set(key, mid);
|
|
85
|
+
}
|
|
86
|
+
let set = mid.get(inner);
|
|
87
|
+
if (!set) {
|
|
88
|
+
set = new Set<string>();
|
|
89
|
+
mid.set(inner, set);
|
|
90
|
+
}
|
|
91
|
+
return set;
|
|
92
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RightsResolver — computes a user's effective permissions for a scope.
|
|
3
|
+
*
|
|
4
|
+
* This is the unification point the epic calls `user.permissions`: every later
|
|
5
|
+
* authorization facet consults `resolve(user, scope)` instead of reading roles
|
|
6
|
+
* or permissions ad hoc.
|
|
7
|
+
*
|
|
8
|
+
* Resolution rules:
|
|
9
|
+
* - Scope chain: `["global"]` for global, `["global", { tenant }]` for a
|
|
10
|
+
* tenant scope — global rights are inherited into every tenant scope.
|
|
11
|
+
* - Roles: token-carried `user.roles` are global identity — they apply at
|
|
12
|
+
* the `global` link only; each scope additionally uses `store.userRoles(...)`.
|
|
13
|
+
* A global role's global permissions still inherit into tenant scopes via
|
|
14
|
+
* the chain, but a payload role never picks up a same-named tenant role's
|
|
15
|
+
* permissions (no cross-tenant escalation by name collision).
|
|
16
|
+
* - Permissions = (∪ over roles of `store.rolePermissions(...)`) ∪
|
|
17
|
+
* `store.userGrants(...)` (the ACL). The payload's `user.permissions` is
|
|
18
|
+
* NOT an input — permissions are derived from the rights model only.
|
|
19
|
+
* - Fail-closed: absent data contributes nothing and never throws.
|
|
20
|
+
* - Exact-match comparison; no wildcard or role-hierarchy expansion.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { UserPayload } from "../AuthManager.js";
|
|
24
|
+
import type { EffectivePermissions, RightsStore, Scope } from "./types.js";
|
|
25
|
+
|
|
26
|
+
class ResolvedPermissions implements EffectivePermissions {
|
|
27
|
+
constructor(
|
|
28
|
+
readonly permissions: ReadonlySet<string>,
|
|
29
|
+
readonly roles: ReadonlySet<string>,
|
|
30
|
+
readonly scope: Scope,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
has(permission: string): boolean {
|
|
34
|
+
return this.permissions.has(permission);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
hasAll(permissions: readonly string[]): boolean {
|
|
38
|
+
return permissions.every((p) => this.permissions.has(p));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hasAny(permissions: readonly string[]): boolean {
|
|
42
|
+
return permissions.some((p) => this.permissions.has(p));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class RightsResolver {
|
|
47
|
+
constructor(private readonly store: RightsStore) {}
|
|
48
|
+
|
|
49
|
+
async resolve(
|
|
50
|
+
user: UserPayload,
|
|
51
|
+
scope: Scope = "global",
|
|
52
|
+
): Promise<EffectivePermissions> {
|
|
53
|
+
const chain: Scope[] = scope === "global" ? ["global"] : ["global", scope];
|
|
54
|
+
|
|
55
|
+
const roles = new Set<string>();
|
|
56
|
+
const permissions = new Set<string>();
|
|
57
|
+
const payloadRoles = user.roles ?? [];
|
|
58
|
+
|
|
59
|
+
for (const link of chain) {
|
|
60
|
+
const storeRoles = await this.store.userRoles(user.id, link);
|
|
61
|
+
// Token-carried roles are global identity: they contribute at the
|
|
62
|
+
// `global` link only, so a payload role never inherits a same-named
|
|
63
|
+
// tenant role's permissions. A global role's global permissions still
|
|
64
|
+
// reach tenant scopes via the chain.
|
|
65
|
+
const scopeRoles =
|
|
66
|
+
link === "global"
|
|
67
|
+
? new Set<string>([...payloadRoles, ...storeRoles])
|
|
68
|
+
: new Set<string>(storeRoles);
|
|
69
|
+
|
|
70
|
+
for (const role of scopeRoles) {
|
|
71
|
+
roles.add(role);
|
|
72
|
+
for (const perm of await this.store.rolePermissions(role, link)) {
|
|
73
|
+
permissions.add(perm);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const grant of await this.store.userGrants(user.id, link)) {
|
|
78
|
+
permissions.add(grant);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return new ResolvedPermissions(permissions, roles, scope);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rights data model — Layer 1 of Warden's unified authorization (Epic 56).
|
|
3
|
+
*
|
|
4
|
+
* The single resolution point: `resolve(user, scope) → EffectivePermissions`.
|
|
5
|
+
* Roles → permissions (RBAC) and direct user → permission grants (ACL) are
|
|
6
|
+
* both keyed by scope (`global` | `{ tenant }`). The store contract is
|
|
7
|
+
* read-only; concrete drivers (the shipped in-memory one, or a copy-in DB
|
|
8
|
+
* adapter) provide seeding/mutation on top.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolution scope. `"global"` is the implicit single-tenant scope; a tenant
|
|
13
|
+
* scope is the discriminated object form so it stays type-distinguishable from
|
|
14
|
+
* the global sentinel at call sites.
|
|
15
|
+
*/
|
|
16
|
+
export type Scope = "global" | { readonly tenant: string };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The single unification point — a user's effective permissions within a scope.
|
|
20
|
+
*/
|
|
21
|
+
export interface EffectivePermissions {
|
|
22
|
+
/** True iff the permission is in the resolved set (exact match). */
|
|
23
|
+
has(permission: string): boolean;
|
|
24
|
+
/** True iff ALL listed permissions are present (empty list ⇒ vacuously true). */
|
|
25
|
+
hasAll(permissions: readonly string[]): boolean;
|
|
26
|
+
/** True iff ANY listed permission is present (empty list ⇒ false). */
|
|
27
|
+
hasAny(permissions: readonly string[]): boolean;
|
|
28
|
+
/** The user's effective permissions in this scope (role-derived ∪ direct grants). */
|
|
29
|
+
readonly permissions: ReadonlySet<string>;
|
|
30
|
+
/** The roles that contributed (payload ∪ store, within the resolved scope incl. global). */
|
|
31
|
+
readonly roles: ReadonlySet<string>;
|
|
32
|
+
/** The scope this was resolved for. */
|
|
33
|
+
readonly scope: Scope;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read contract consulted by the resolver. All methods scoped + async: a
|
|
38
|
+
* DB-backed adapter is inherently async, and the evaluation layer (56.2) is
|
|
39
|
+
* async end-to-end. Writes are NOT part of this contract — drivers add their
|
|
40
|
+
* own seeding API.
|
|
41
|
+
*/
|
|
42
|
+
export interface RightsStore {
|
|
43
|
+
/** Permissions a role grants in the given scope (unknown role ⇒ []). */
|
|
44
|
+
rolePermissions(role: string, scope: Scope): Promise<readonly string[]>;
|
|
45
|
+
/** Roles assigned to a user in the given scope (none ⇒ []). */
|
|
46
|
+
userRoles(userId: string, scope: Scope): Promise<readonly string[]>;
|
|
47
|
+
/** Direct per-user permission grants (ACL) in the given scope (none ⇒ []). */
|
|
48
|
+
userGrants(userId: string, scope: Scope): Promise<readonly string[]>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stable string key for a scope. `"global"` → `"global"`,
|
|
53
|
+
* `{ tenant: "X" }` → `"tenant:X"`. Used by drivers to key their storage and
|
|
54
|
+
* by 56.3 when threading scope through the evaluation context.
|
|
55
|
+
*/
|
|
56
|
+
export function scopeKey(scope: Scope): string {
|
|
57
|
+
return scope === "global" ? "global" : `tenant:${scope.tenant}`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `AuthManager` singleton — mirror of Adonis's
|
|
3
|
+
* `import auth from '@adonisjs/auth/services/main'` shape.
|
|
4
|
+
*
|
|
5
|
+
* import auth from '@c9up/warden/services/main'
|
|
6
|
+
*
|
|
7
|
+
* const result = await auth.verify(bearerToken)
|
|
8
|
+
* if (result.authenticated) { ... }
|
|
9
|
+
*
|
|
10
|
+
* Populated by `WardenProvider.boot()`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AuthManager } from "../AuthManager.js";
|
|
14
|
+
|
|
15
|
+
let _instance: AuthManager | undefined;
|
|
16
|
+
|
|
17
|
+
/** @internal Bind the singleton (called by WardenProvider). */
|
|
18
|
+
export function _setAuth(instance: AuthManager): void {
|
|
19
|
+
_instance = instance;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @internal Read the singleton (or `undefined` pre-boot). */
|
|
23
|
+
export function _getAuth(): AuthManager | undefined {
|
|
24
|
+
return _instance;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const auth: AuthManager = new Proxy({} as AuthManager, {
|
|
28
|
+
get(_target, prop) {
|
|
29
|
+
if (!_instance) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"[warden] AuthManager singleton accessed before WardenProvider.boot() ran. " +
|
|
32
|
+
"Check that `@c9up/warden/provider` is listed in your reamrc.ts providers.",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const value = Reflect.get(_instance, prop, _instance);
|
|
36
|
+
return typeof value === "function" ? value.bind(_instance) : value;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export default auth;
|