@gnldev/auth 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 +201 -0
- package/README.md +138 -0
- package/dist/adapter.d.ts +38 -0
- package/dist/adapter.js +79 -0
- package/dist/adapter.js.map +1 -0
- package/dist/gate.d.ts +32 -0
- package/dist/gate.js +122 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/role-auth.d.ts +34 -0
- package/dist/role-auth.js +199 -0
- package/dist/role-auth.js.map +1 -0
- package/dist/safe-equal.d.ts +2 -0
- package/dist/safe-equal.js +15 -0
- package/dist/safe-equal.js.map +1 -0
- package/dist/same-site.d.ts +15 -0
- package/dist/same-site.js +57 -0
- package/dist/same-site.js.map +1 -0
- package/dist/scope.d.ts +53 -0
- package/dist/scope.js +50 -0
- package/dist/scope.js.map +1 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
package/dist/gate.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"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","sourcesContent":["// The shared gate: hosts (server/studio) don't rewrite allow/deny logic.\n//\n// Takes a web-standard `Request`, not a framework's context object. A host writing an auth callback\n// used to need Hono's `Context` type in its own signature, which quietly tied its Hono version to\n// ours — the same coupling the factories dropped when they stopped returning a Hono app. Everything\n// the gate reads (method, path, headers, query) is on `Request`; nothing was lost by narrowing.\nimport type { AuthProvider, Decision, Principal } from './types.js';\nimport { isCrossSiteStateChange } from './same-site.js';\nimport { bindsIdentity } from './adapter.js';\n\nexport interface Gate {\n /** Is access allowed? true if there's no provider (opt-in: gate not set up → open; see makeGate in production). */\n allow(req: Request, action: 'read' | 'write', resource?: string): Promise<boolean>;\n /**\n * Fine-grained variant of allow(): checks a SPECIFIC permission (e.g. 'agents:run', 'users:write').\n * • No provider (auth off) → true (unchanged opt-in behavior).\n * • EE / RBAC provider → the permission is passed through `AuthContext.permission` and matched against\n * the principal's EFFECTIVE permissions (explicit `permissions[]` ?? role grants) — see rbac.ts.\n * • Free / read-write provider → the permission is REDUCED to read/write (`:read` suffix → read, else\n * write) and evaluated coarsely. This makes `allowP(req,'X:write') ≡ allow(req,'write')` and\n * `allowP(req,'*:read') ≡ allow(req,'read')` in the free tier → no regression.\n * A denial records its decision against the request (like allow) so `deny()` can surface status/reason.\n */\n allowP(req: Request, permission: string): Promise<boolean>;\n /** Return the denial. If allow() attached its last decision to the context, use its status/reason. */\n deny(req: Request, action: 'read' | 'write'): Response;\n}\n\n// Per-request state, keyed by the request itself rather than stashed as a property on it. A Request\n// is somebody else's object; writing hidden fields onto it worked, but it also meant two libraries\n// could pick the same key. A WeakMap cannot collide and cannot leak — the entry dies with the request.\nconst principals = new WeakMap<Request, Principal>();\nconst decisions = new WeakMap<Request, Decision>();\n\n/**\n * The principal authenticated during allow() (within the same request). Hosts derive the organization\n * scope and audit actor from here → closed to header spoofing. Null if allow() hasn't been called yet.\n */\nexport function principalOf(req: Request): Principal | null {\n return principals.get(req) ?? null;\n}\n\nexport interface GateOptions {\n /**\n * DELIBERATE permission for a providerless gate in production. Auth stays opt-in; but silent\n * fail-open is impossible under NODE_ENV=production — either a provider is given or this flag is\n * explicitly set to true (audit #2).\n */\n allowOpenAccess?: boolean;\n}\n\nexport function makeGate(provider?: AuthProvider, opts?: GateOptions): Gate {\n // Fail-open audit at SETUP time: in production, a providerless gate can only be set up with the deliberate flag.\n // (If left to request time, the error would blow up on the first request after deploy — an early, clear failure was preferred.)\n if (!provider && process.env.NODE_ENV === 'production' && opts?.allowOpenAccess !== true) {\n throw new Error(\n 'auth is required in production; for deliberately open access, set allowOpenAccess: true (see @gnldev/auth makeGate / host options)',\n );\n }\n // Non-production providerless gate: warn ONCE on the first request (no silent openness), then open.\n let warnedOpen = false;\n let warnedCrossSite = false;\n\n /**\n * The providerless decision, in ONE place.\n *\n * It was written inline in `allow()` only, so `allowP()` — the fine-grained entry point, and the one\n * 23 endpoints across @gnldev/server and @gnldev/studio actually call — kept an unconditional\n * `return true`. Which meant the cross-site block covered the coarse path and left the specific one\n * open: a page on another site could still drive every endpoint that asks for a named permission.\n * Two copies of a security decision is one copy too many.\n */\n /**\n * Is this request a cross-site state change against a surface with NO IDENTITY to ride?\n *\n * The condition is about identity, not about the presence of a provider — and getting that wrong\n * left a hole. A legacy `{read,write}` pair IS a provider after normalizeAuth, so keying on\n * `!provider` skipped the check for it; but that pair has no principal model at all (its\n * authenticate() is `return null` by construction), so there is no credential a cross-site page\n * could be riding, and its predicate answers on the request alone. Measured: with\n * `{read:()=>true, write:()=>true}` configured, a cross-site `POST /api/retention/sweep` was\n * ALLOWED. examples/app ships exactly that shape, so it was the published example that was open.\n *\n * `bindsIdentity(undefined)` is false, so the providerless case is covered by the same test.\n */\n function crossSiteWithoutIdentity(req: Request): boolean {\n if (bindsIdentity(provider) || !isCrossSiteStateChange(req)) return false;\n if (!warnedCrossSite) {\n warnedCrossSite = true;\n console.warn(\n '@gnldev/auth: blocked a cross-site write to a surface with no identity to authenticate ' +\n 'against. A page on another site attempted a state-changing request. Configure an auth ' +\n 'provider that binds an identity if this surface is meant to be reachable by other origins.',\n );\n }\n return true;\n }\n\n function openSurfaceAllows(req: Request): boolean {\n // The warning is an INSTRUCTION (\"for deliberate open access use allowOpenAccess: true\"), so it\n // must stop once the instruction has been followed. It did not: the flag suppressed the\n // production throw but not this line, so a developer who set it kept being told to set it — which\n // teaches that the flag is inert and that this package's auth warnings can be ignored. Under\n // NODE_ENV=production the same flag is already accepted as the whole declaration of intent; there\n // is no reason for dev to demand it twice and then not honour it.\n //\n // Openness WITHOUT the flag still warns, every process, exactly as before — that is the case the\n // message was written for.\n if (!warnedOpen && opts?.allowOpenAccess !== true && process.env.NODE_ENV !== 'production') {\n warnedOpen = true;\n console.warn(\n '@gnldev/auth: no provider given → ALL endpoints are open (opt-in gate not set up). Add auth before production; for deliberate open access use allowOpenAccess: true.',\n );\n }\n return true;\n }\n return {\n async allow(req, action, resource) {\n if (crossSiteWithoutIdentity(req)) return false;\n if (!provider) return openSurfaceAllows(req);\n const principal = await provider.authenticate(req);\n if (principal) principals.set(req, principal);\n const decision = await provider.authorize(principal, req, {\n path: new URL(req.url).pathname,\n method: req.method,\n action,\n resource,\n });\n if (!decision.allow) decisions.set(req, decision);\n return decision.allow;\n },\n async allowP(req, permission) {\n if (crossSiteWithoutIdentity(req)) return false;\n if (!provider) return openSurfaceAllows(req);\n const principal = await provider.authenticate(req);\n if (principal) principals.set(req, principal);\n // Free-tier reduction: anything ending in ':read' is a read, everything else is a write. An RBAC\n // provider ignores `action` and matches `permission` exactly; a free provider uses this reduced action.\n const action: 'read' | 'write' = permission.endsWith(':read') ? 'read' : 'write';\n const decision = await provider.authorize(principal, req, {\n path: new URL(req.url).pathname,\n method: req.method,\n action,\n permission,\n });\n if (!decision.allow) decisions.set(req, decision);\n return decision.allow;\n },\n deny(req, action) {\n const d = decisions.get(req);\n const denied = d && d.allow === false ? d : undefined;\n const status = denied?.status ?? (action === 'write' ? 403 : 401);\n const reason = denied?.reason ?? (action === 'write' ? 'unauthorized (admin required)' : 'unauthorized');\n return Response.json({ error: reason }, { status });\n },\n };\n}\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { Principal, AuthContext, Decision, AuthCapabilities, AuthProvider, Cred } from './types.js';
|
|
2
|
+
export { roleAuth, CLIENT_ROLE, CLIENT_WRITES } from './role-auth.js';
|
|
3
|
+
export { makeGate, principalOf, type Gate, type GateOptions } from './gate.js';
|
|
4
|
+
export { fromReadWrite, normalizeAuth, bindsIdentity, type ReadWriteAuth } from './adapter.js';
|
|
5
|
+
export { safeEqual } from './safe-equal.js';
|
|
6
|
+
export { isCrossSiteStateChange } from './same-site.js';
|
|
7
|
+
export { PLATFORM_ADMIN_ROLE, isPlatformAdmin, principalScope, assertAssignablePrivileges, type PrincipalScope, type AssignabilityResult } from './scope.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { roleAuth, CLIENT_ROLE, CLIENT_WRITES } from './role-auth.js';
|
|
2
|
+
export { makeGate, principalOf } from './gate.js';
|
|
3
|
+
export { fromReadWrite, normalizeAuth, bindsIdentity } from './adapter.js';
|
|
4
|
+
export { safeEqual } from './safe-equal.js';
|
|
5
|
+
export { isCrossSiteStateChange } from './same-site.js';
|
|
6
|
+
export { PLATFORM_ADMIN_ROLE, isPlatformAdmin, principalScope, assertAssignablePrivileges } from './scope.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAA+B,MAAM,WAAW,CAAC;AAC/E,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,aAAa,EAAsB,MAAM,cAAc,CAAC;AAC/F,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,cAAc,EAAE,0BAA0B,EAAiD,MAAM,YAAY,CAAC","sourcesContent":["// @gnldev/auth — open-core auth seam:\n// AuthProvider → stable contract (server + studio gate against this)\n// RoleAuth → free default (bearer/basic; superAdmin/admin/client/viewer)\n// makeGate → shared Hono gate (allow/deny)\n// normalizeAuth → AuthProvider | {read,write} backward-compat bridge\nexport type { Principal, AuthContext, Decision, AuthCapabilities, AuthProvider, Cred } from './types.js';\nexport { roleAuth, CLIENT_ROLE, CLIENT_WRITES } from './role-auth.js';\nexport { makeGate, principalOf, type Gate, type GateOptions } from './gate.js';\nexport { fromReadWrite, normalizeAuth, bindsIdentity, type ReadWriteAuth } from './adapter.js';\nexport { safeEqual } from './safe-equal.js';\nexport { isCrossSiteStateChange } from './same-site.js';\nexport { PLATFORM_ADMIN_ROLE, isPlatformAdmin, principalScope, assertAssignablePrivileges, type PrincipalScope, type AssignabilityResult } from './scope.js';\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AuthProvider, Cred } from './types.js';
|
|
2
|
+
/** The reserved role naming the application credential class (see `CLIENT_WRITES`). */
|
|
3
|
+
export declare const CLIENT_ROLE = "client";
|
|
4
|
+
/**
|
|
5
|
+
* The ONLY writes an application credential may perform — running work, and stopping work it started.
|
|
6
|
+
* Everything else (organization/user/pricing/policy administration, the agent registry, retention)
|
|
7
|
+
* belongs to `admin`.
|
|
8
|
+
*
|
|
9
|
+
* Named permissions only: `AuthContext.permission` is set by the gate's `allowP`, so a route gated
|
|
10
|
+
* with the coarse `allow(req, 'write')` arrives here with `permission: undefined` and is DENIED for
|
|
11
|
+
* this class. That is the point — see the whitelist note in the module header.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CLIENT_WRITES: ReadonlySet<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Role-based free auth. Each class can be configured with a bearer token and/or basic user+pass (both
|
|
16
|
+
* fall into the same class). If NO class is given, returns `undefined` → OPT-IN: the gate isn't set
|
|
17
|
+
* up, the existing open behavior is preserved.
|
|
18
|
+
*
|
|
19
|
+
* `{ admin, viewer }` behaves exactly as it did before `superAdmin`/`client` existed — the two new
|
|
20
|
+
* classes are additive, and a config that names neither produces byte-identical decisions.
|
|
21
|
+
*/
|
|
22
|
+
export declare function roleAuth(cfg: {
|
|
23
|
+
/**
|
|
24
|
+
* The PLATFORM operator: sees and manages every organization. Unbound by design, and says so —
|
|
25
|
+
* this class carries the reserved `platform-admin` role, which is what the strict/org-isolation
|
|
26
|
+
* models check. `scope.ts` refuses to INFER the platform scope from a missing `orgId`, because
|
|
27
|
+
* forgetting an orgId would then mint a super-admin; naming the class is that grant made explicit.
|
|
28
|
+
*/
|
|
29
|
+
superAdmin?: Cred;
|
|
30
|
+
admin?: Cred;
|
|
31
|
+
/** An application's server-to-server credential: runs agents, manages nothing (see `CLIENT_WRITES`). */
|
|
32
|
+
client?: Cred;
|
|
33
|
+
viewer?: Cred;
|
|
34
|
+
}): AuthProvider | undefined;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Free default auth provider: four credential CLASSES, bearer + basic.
|
|
2
|
+
// (The paid @gnldev/auth-ee implements the same AuthProvider with SSO/RBAC.)
|
|
3
|
+
//
|
|
4
|
+
// The classes exist because "admin" was doing two unrelated jobs, and measurement showed the cost of
|
|
5
|
+
// each. (1) An application's day-to-day calls — running an agent, sending a prompt — required the
|
|
6
|
+
// `admin` token, the SAME token that cancels runs, reads the whole organization's history and reads
|
|
7
|
+
// `/usage`. A customer's backend therefore carried the deployment's management key to do routine work.
|
|
8
|
+
// (2) The operator who runs the PLATFORM belongs to no single organization by construction, so the
|
|
9
|
+
// org-isolation fail-closed rule (server/studio `orgIsolationActive`) denied it along with the
|
|
10
|
+
// accidental unbound admin it was written to stop — 39 tests across 11 files were that persona.
|
|
11
|
+
//
|
|
12
|
+
// superAdmin the platform operator. Unbound BY DESIGN → carries `platform-admin` (scope.ts).
|
|
13
|
+
// admin an organization's manager. Studio, governance, everything inside one org.
|
|
14
|
+
// client an application's server-to-server credential. Runs agents; manages nothing.
|
|
15
|
+
// viewer read-only.
|
|
16
|
+
//
|
|
17
|
+
// `client` is deliberately a WHITELIST, not "admin minus a few things": a write whose permission this
|
|
18
|
+
// file does not name is denied. A route added later without a name costs a 403 (visible, reported)
|
|
19
|
+
// instead of silently widening what every deployed application credential can reach.
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { safeEqual } from './safe-equal.js';
|
|
22
|
+
import { PLATFORM_ADMIN_ROLE } from './scope.js';
|
|
23
|
+
/** The reserved role naming the application credential class (see `CLIENT_WRITES`). */
|
|
24
|
+
export const CLIENT_ROLE = 'client';
|
|
25
|
+
/**
|
|
26
|
+
* The ONLY writes an application credential may perform — running work, and stopping work it started.
|
|
27
|
+
* Everything else (organization/user/pricing/policy administration, the agent registry, retention)
|
|
28
|
+
* belongs to `admin`.
|
|
29
|
+
*
|
|
30
|
+
* Named permissions only: `AuthContext.permission` is set by the gate's `allowP`, so a route gated
|
|
31
|
+
* with the coarse `allow(req, 'write')` arrives here with `permission: undefined` and is DENIED for
|
|
32
|
+
* this class. That is the point — see the whitelist note in the module header.
|
|
33
|
+
*/
|
|
34
|
+
export const CLIENT_WRITES = new Set([
|
|
35
|
+
'agents:run',
|
|
36
|
+
'workflow:run',
|
|
37
|
+
'run:cancel',
|
|
38
|
+
]);
|
|
39
|
+
/** Basic auth header value (same base64 logic as studio basicAuth). */
|
|
40
|
+
function basicValue(user, pass) {
|
|
41
|
+
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Stable per-token identity for a class configured with a bearer token and no `user` — i.e. exactly
|
|
45
|
+
* the shape `gnl add host` scaffolds (`{ token: v }`, see packages/cli/src/hosts.ts's `APP_TS`) and
|
|
46
|
+
* the shape `GNL_ADMIN_TOKEN`/`GNL_VIEWER_TOKEN` produce. Without this, such a principal's `id` was
|
|
47
|
+
* always absent, and anything keyed on `Principal.id` — @gnldev/studio's dead-letter-scan admission
|
|
48
|
+
* budget being the measured case (`server.ts`'s `deadScanCaller`) — could not tell two holders of
|
|
49
|
+
* DIFFERENT tokens apart, so the fairness bound it exists for silently did not apply to a single
|
|
50
|
+
* deployment's own admin/viewer tokens.
|
|
51
|
+
*
|
|
52
|
+
* A hash, not the token itself, so that whatever does read it never holds something that unlocks the
|
|
53
|
+
* credential. WHAT READS IT TODAY is exactly one thing: studio's dead-letter admission map
|
|
54
|
+
* (`deadScanCaller`). Audit records (`@gnldev/auth-ee`'s `audit.ts`) and `GET /me` read `principal.id`
|
|
55
|
+
* and do not see this field at all — an earlier draft of this note claimed they did, from when the
|
|
56
|
+
* fingerprint still lived on `id`. It is stated as a fact about today rather than a guarantee: if a
|
|
57
|
+
* surface ever does expose it, the dictionary-attack note below becomes live rather than theoretical.
|
|
58
|
+
* `sha256`, prefixed and NOT truncated: a short/truncated digest of a low-entropy,
|
|
59
|
+
* human-picked token is realistically dictionary-attackable regardless of digest length, so
|
|
60
|
+
* truncating would only look safer; keeping the full 32-byte digest doesn't make that easier than it
|
|
61
|
+
* already is. The `token:` prefix marks the value as SYNTHETIC (never a real username/user-store id)
|
|
62
|
+
* everywhere it surfaces, matching the one already in use for the `platform-admin` role tag.
|
|
63
|
+
*
|
|
64
|
+
* It lands on `credentialId`, NOT on `id`, and every class gets one including `client`. The first
|
|
65
|
+
* attempt put it on `id` and excluded `client` to protect that class's documented contract; measuring
|
|
66
|
+
* the other classes showed the exclusion was aimed at the wrong thing. `resolveResourceId` prefers
|
|
67
|
+
* `principal.id` UNCONDITIONALLY over the subject a request named, so an operator that sent
|
|
68
|
+
* `resourceId: 'user-42'` got the fingerprint back instead — `user-42` and `user-99` in one bucket,
|
|
69
|
+
* silently. The reasoning that made this look safe was that an operator "works across an
|
|
70
|
+
* organization's data by design and names nobody"; that sentence explains why an operator is not
|
|
71
|
+
* REQUIRED to name a subject, not that it never does, and the code accepted one before.
|
|
72
|
+
*
|
|
73
|
+
* `credentialId` has no such reach: it is a budget key, nothing treats it as an owner, so `client` can
|
|
74
|
+
* carry one too — a customer's backend is a legitimate subject for a spending limit while remaining,
|
|
75
|
+
* as designed, no subject at all for memory. `id` keeps its old meaning exactly: the basic-auth `user`
|
|
76
|
+
* when there is one, otherwise absent, and that absence still tells `resolveResourceId` the truth.
|
|
77
|
+
* This also leaves `actorOf`'s `x-gnl-actor` attribution untouched, which the first attempt overrode.
|
|
78
|
+
*/
|
|
79
|
+
function tokenId(token) {
|
|
80
|
+
return `token:${createHash('sha256').update(token, 'utf8').digest('hex')}`;
|
|
81
|
+
}
|
|
82
|
+
/** Accepted Authorization header values + bearer token set for a role (for SSE query fallback). */
|
|
83
|
+
function credValues(cred) {
|
|
84
|
+
const headers = new Set();
|
|
85
|
+
const tokens = new Set();
|
|
86
|
+
if (cred?.token) {
|
|
87
|
+
headers.add(`Bearer ${cred.token}`);
|
|
88
|
+
tokens.add(cred.token);
|
|
89
|
+
}
|
|
90
|
+
if (cred?.user && cred?.pass)
|
|
91
|
+
headers.add(basicValue(cred.user, cred.pass));
|
|
92
|
+
return { headers, tokens };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Role-based free auth. Each class can be configured with a bearer token and/or basic user+pass (both
|
|
96
|
+
* fall into the same class). If NO class is given, returns `undefined` → OPT-IN: the gate isn't set
|
|
97
|
+
* up, the existing open behavior is preserved.
|
|
98
|
+
*
|
|
99
|
+
* `{ admin, viewer }` behaves exactly as it did before `superAdmin`/`client` existed — the two new
|
|
100
|
+
* classes are additive, and a config that names neither produces byte-identical decisions.
|
|
101
|
+
*/
|
|
102
|
+
export function roleAuth(cfg) {
|
|
103
|
+
const superAdmin = credValues(cfg.superAdmin);
|
|
104
|
+
const admin = credValues(cfg.admin);
|
|
105
|
+
const client = credValues(cfg.client);
|
|
106
|
+
const viewer = credValues(cfg.viewer);
|
|
107
|
+
if (superAdmin.headers.size === 0 && admin.headers.size === 0 && client.headers.size === 0 && viewer.headers.size === 0) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
// safeEqual loop instead of Set.has (===): so the secret comparison is constant-time (the number of
|
|
111
|
+
// accepted tokens/basics per role is small — loop cost is negligible).
|
|
112
|
+
const matchRole = (req, role) => {
|
|
113
|
+
const h = req.headers.get('authorization') ?? undefined;
|
|
114
|
+
if (h && [...role.headers].some((v) => safeEqual(h, v)))
|
|
115
|
+
return true;
|
|
116
|
+
/**
|
|
117
|
+
* EventSource can't send headers → ?token= bearer fallback (bearer tokens only).
|
|
118
|
+
* SECURITY NOTE — log-leak risk: a token carried in the query string can leak into web
|
|
119
|
+
* server/proxy access logs, browser history, and (if the URL is shared/redirected) the
|
|
120
|
+
* `Referer` header. This fallback only remains because of the EventSource constraint; prefer
|
|
121
|
+
* @gnldev/studio's short-lived (60s TTL) one-time `POST /auth/sse-ticket` → `?ticket=` flow where
|
|
122
|
+
* possible (see the `/events` endpoint in packages/studio/src/server.ts) — the persistent secret
|
|
123
|
+
* is never carried in the URL.
|
|
124
|
+
*/
|
|
125
|
+
// GET only. The constraint this exists for is EventSource, which cannot send headers and only
|
|
126
|
+
// ever issues a GET — so accepting it on POST/DELETE bought nothing, and it authenticated an
|
|
127
|
+
// admin retention purge from a URL, which lands in proxy logs, browser history and `Referer`.
|
|
128
|
+
// The short-lived `?ticket=` flow named above is the preferred path even for the GET.
|
|
129
|
+
if (req.method.toUpperCase() !== 'GET')
|
|
130
|
+
return false;
|
|
131
|
+
const q = new URL(req.url).searchParams.get('token') ?? undefined;
|
|
132
|
+
return !!q && [...role.tokens].some((v) => safeEqual(q, v));
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Principal of the matched class: id + organization bound to the identity (Cred.orgId).
|
|
136
|
+
*
|
|
137
|
+
* `id` is the basic-auth `user` and nothing else — unchanged, pre-existing behavior. A bearer token
|
|
138
|
+
* yields no `id`, and that absence is what tells `resolveResourceId` this deployment has no
|
|
139
|
+
* per-caller subject. `credentialId` is filled separately, for every class, from `tokenId` — a
|
|
140
|
+
* stable fingerprint of the credential that presented itself, for budget/admission keying only (see
|
|
141
|
+
* `tokenId` and the `Principal.credentialId` doc comment for why the two must not be merged).
|
|
142
|
+
*
|
|
143
|
+
* `superAdmin` carries `admin` too, so every existing `roles.includes('admin')` check — here, in
|
|
144
|
+
* @gnldev/server, in @gnldev/studio — keeps working without knowing the class exists; the added
|
|
145
|
+
* `platform-admin` is what widens its SCOPE (scope.ts `principalScope`).
|
|
146
|
+
*
|
|
147
|
+
* The pre-existing `platformAdmin: true` cred flag still injects the same role on any class, so
|
|
148
|
+
* configs written before `superAdmin` existed are unaffected. Prefer the class: it says which
|
|
149
|
+
* credential this IS, rather than adding a privilege to one that reads as an ordinary admin.
|
|
150
|
+
*/
|
|
151
|
+
const principalOfRole = (role, cred, platform = false) => {
|
|
152
|
+
const roles = role === 'admin' && platform ? ['admin', PLATFORM_ADMIN_ROLE] : [role];
|
|
153
|
+
const credentialId = cred?.token ? tokenId(cred.token) : undefined;
|
|
154
|
+
return {
|
|
155
|
+
roles: cred?.platformAdmin && !roles.includes(PLATFORM_ADMIN_ROLE) ? [...roles, PLATFORM_ADMIN_ROLE] : roles,
|
|
156
|
+
...(cred?.user ? { id: cred.user } : {}),
|
|
157
|
+
...(credentialId ? { credentialId } : {}),
|
|
158
|
+
...(cred?.orgId ? { orgId: cred.orgId } : {}),
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
authenticate(req) {
|
|
163
|
+
// Most-privileged first: a token configured for two classes resolves to the stronger one, which
|
|
164
|
+
// is the safe direction for `authenticate` (the weaker class would silently under-authorize a
|
|
165
|
+
// credential the host declared as an operator).
|
|
166
|
+
if (superAdmin.headers.size && matchRole(req, superAdmin))
|
|
167
|
+
return principalOfRole('admin', cfg.superAdmin, true);
|
|
168
|
+
if (admin.headers.size && matchRole(req, admin))
|
|
169
|
+
return principalOfRole('admin', cfg.admin);
|
|
170
|
+
if (client.headers.size && matchRole(req, client))
|
|
171
|
+
return principalOfRole(CLIENT_ROLE, cfg.client);
|
|
172
|
+
if (viewer.headers.size && matchRole(req, viewer))
|
|
173
|
+
return principalOfRole('viewer', cfg.viewer);
|
|
174
|
+
return null;
|
|
175
|
+
},
|
|
176
|
+
authorize(principal, _req, ctx) {
|
|
177
|
+
const roles = principal?.roles ?? [];
|
|
178
|
+
if (ctx.action === 'write') {
|
|
179
|
+
if (roles.includes('admin'))
|
|
180
|
+
return { allow: true };
|
|
181
|
+
// An application credential may only perform writes this file NAMES. `ctx.permission` is
|
|
182
|
+
// undefined for a route gated with the coarse `allow(req,'write')` — denied, deliberately.
|
|
183
|
+
if (roles.includes(CLIENT_ROLE)) {
|
|
184
|
+
return ctx.permission && CLIENT_WRITES.has(ctx.permission)
|
|
185
|
+
? { allow: true }
|
|
186
|
+
: { allow: false, status: 403, reason: `unauthorized (client credentials cannot ${ctx.permission ?? 'perform this write'})` };
|
|
187
|
+
}
|
|
188
|
+
return { allow: false, status: 403, reason: 'unauthorized (admin required)' };
|
|
189
|
+
}
|
|
190
|
+
return roles.includes('admin') || roles.includes(CLIENT_ROLE) || roles.includes('viewer')
|
|
191
|
+
? { allow: true }
|
|
192
|
+
: { allow: false, status: 401, reason: 'unauthorized' };
|
|
193
|
+
},
|
|
194
|
+
capabilities() {
|
|
195
|
+
return { sso: false, rbac: false, audit: false, multiOrganization: false, users: false };
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=role-auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-auth.js","sourceRoot":"","sources":["../src/role-auth.ts"],"names":[],"mappings":"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","sourcesContent":["// Free default auth provider: four credential CLASSES, bearer + basic.\n// (The paid @gnldev/auth-ee implements the same AuthProvider with SSO/RBAC.)\n//\n// The classes exist because \"admin\" was doing two unrelated jobs, and measurement showed the cost of\n// each. (1) An application's day-to-day calls — running an agent, sending a prompt — required the\n// `admin` token, the SAME token that cancels runs, reads the whole organization's history and reads\n// `/usage`. A customer's backend therefore carried the deployment's management key to do routine work.\n// (2) The operator who runs the PLATFORM belongs to no single organization by construction, so the\n// org-isolation fail-closed rule (server/studio `orgIsolationActive`) denied it along with the\n// accidental unbound admin it was written to stop — 39 tests across 11 files were that persona.\n//\n// superAdmin the platform operator. Unbound BY DESIGN → carries `platform-admin` (scope.ts).\n// admin an organization's manager. Studio, governance, everything inside one org.\n// client an application's server-to-server credential. Runs agents; manages nothing.\n// viewer read-only.\n//\n// `client` is deliberately a WHITELIST, not \"admin minus a few things\": a write whose permission this\n// file does not name is denied. A route added later without a name costs a 403 (visible, reported)\n// instead of silently widening what every deployed application credential can reach.\nimport { createHash } from 'node:crypto';\nimport type { AuthProvider, Principal, Decision, AuthContext, Cred } from './types.js';\nimport { safeEqual } from './safe-equal.js';\nimport { PLATFORM_ADMIN_ROLE } from './scope.js';\n\n/** The reserved role naming the application credential class (see `CLIENT_WRITES`). */\nexport const CLIENT_ROLE = 'client';\n\n/**\n * The ONLY writes an application credential may perform — running work, and stopping work it started.\n * Everything else (organization/user/pricing/policy administration, the agent registry, retention)\n * belongs to `admin`.\n *\n * Named permissions only: `AuthContext.permission` is set by the gate's `allowP`, so a route gated\n * with the coarse `allow(req, 'write')` arrives here with `permission: undefined` and is DENIED for\n * this class. That is the point — see the whitelist note in the module header.\n */\nexport const CLIENT_WRITES: ReadonlySet<string> = new Set([\n 'agents:run',\n 'workflow:run',\n 'run:cancel',\n]);\n\n/** Basic auth header value (same base64 logic as studio basicAuth). */\nfunction basicValue(user: string, pass: string): string {\n return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');\n}\n\n/**\n * Stable per-token identity for a class configured with a bearer token and no `user` — i.e. exactly\n * the shape `gnl add host` scaffolds (`{ token: v }`, see packages/cli/src/hosts.ts's `APP_TS`) and\n * the shape `GNL_ADMIN_TOKEN`/`GNL_VIEWER_TOKEN` produce. Without this, such a principal's `id` was\n * always absent, and anything keyed on `Principal.id` — @gnldev/studio's dead-letter-scan admission\n * budget being the measured case (`server.ts`'s `deadScanCaller`) — could not tell two holders of\n * DIFFERENT tokens apart, so the fairness bound it exists for silently did not apply to a single\n * deployment's own admin/viewer tokens.\n *\n * A hash, not the token itself, so that whatever does read it never holds something that unlocks the\n * credential. WHAT READS IT TODAY is exactly one thing: studio's dead-letter admission map\n * (`deadScanCaller`). Audit records (`@gnldev/auth-ee`'s `audit.ts`) and `GET /me` read `principal.id`\n * and do not see this field at all — an earlier draft of this note claimed they did, from when the\n * fingerprint still lived on `id`. It is stated as a fact about today rather than a guarantee: if a\n * surface ever does expose it, the dictionary-attack note below becomes live rather than theoretical.\n * `sha256`, prefixed and NOT truncated: a short/truncated digest of a low-entropy,\n * human-picked token is realistically dictionary-attackable regardless of digest length, so\n * truncating would only look safer; keeping the full 32-byte digest doesn't make that easier than it\n * already is. The `token:` prefix marks the value as SYNTHETIC (never a real username/user-store id)\n * everywhere it surfaces, matching the one already in use for the `platform-admin` role tag.\n *\n * It lands on `credentialId`, NOT on `id`, and every class gets one including `client`. The first\n * attempt put it on `id` and excluded `client` to protect that class's documented contract; measuring\n * the other classes showed the exclusion was aimed at the wrong thing. `resolveResourceId` prefers\n * `principal.id` UNCONDITIONALLY over the subject a request named, so an operator that sent\n * `resourceId: 'user-42'` got the fingerprint back instead — `user-42` and `user-99` in one bucket,\n * silently. The reasoning that made this look safe was that an operator \"works across an\n * organization's data by design and names nobody\"; that sentence explains why an operator is not\n * REQUIRED to name a subject, not that it never does, and the code accepted one before.\n *\n * `credentialId` has no such reach: it is a budget key, nothing treats it as an owner, so `client` can\n * carry one too — a customer's backend is a legitimate subject for a spending limit while remaining,\n * as designed, no subject at all for memory. `id` keeps its old meaning exactly: the basic-auth `user`\n * when there is one, otherwise absent, and that absence still tells `resolveResourceId` the truth.\n * This also leaves `actorOf`'s `x-gnl-actor` attribution untouched, which the first attempt overrode.\n */\nfunction tokenId(token: string): string {\n return `token:${createHash('sha256').update(token, 'utf8').digest('hex')}`;\n}\n\n/** Accepted Authorization header values + bearer token set for a role (for SSE query fallback). */\nfunction credValues(cred?: Cred): { headers: Set<string>; tokens: Set<string> } {\n const headers = new Set<string>();\n const tokens = new Set<string>();\n if (cred?.token) {\n headers.add(`Bearer ${cred.token}`);\n tokens.add(cred.token);\n }\n if (cred?.user && cred?.pass) headers.add(basicValue(cred.user, cred.pass));\n return { headers, tokens };\n}\n\n/**\n * Role-based free auth. Each class can be configured with a bearer token and/or basic user+pass (both\n * fall into the same class). If NO class is given, returns `undefined` → OPT-IN: the gate isn't set\n * up, the existing open behavior is preserved.\n *\n * `{ admin, viewer }` behaves exactly as it did before `superAdmin`/`client` existed — the two new\n * classes are additive, and a config that names neither produces byte-identical decisions.\n */\nexport function roleAuth(cfg: {\n /**\n * The PLATFORM operator: sees and manages every organization. Unbound by design, and says so —\n * this class carries the reserved `platform-admin` role, which is what the strict/org-isolation\n * models check. `scope.ts` refuses to INFER the platform scope from a missing `orgId`, because\n * forgetting an orgId would then mint a super-admin; naming the class is that grant made explicit.\n */\n superAdmin?: Cred;\n admin?: Cred;\n /** An application's server-to-server credential: runs agents, manages nothing (see `CLIENT_WRITES`). */\n client?: Cred;\n viewer?: Cred;\n}): AuthProvider | undefined {\n const superAdmin = credValues(cfg.superAdmin);\n const admin = credValues(cfg.admin);\n const client = credValues(cfg.client);\n const viewer = credValues(cfg.viewer);\n if (superAdmin.headers.size === 0 && admin.headers.size === 0 && client.headers.size === 0 && viewer.headers.size === 0) {\n return undefined;\n }\n\n // safeEqual loop instead of Set.has (===): so the secret comparison is constant-time (the number of\n // accepted tokens/basics per role is small — loop cost is negligible).\n const matchRole = (req: Request, role: { headers: Set<string>; tokens: Set<string> }): boolean => {\n const h = req.headers.get('authorization') ?? undefined;\n if (h && [...role.headers].some((v) => safeEqual(h, v))) return true;\n /**\n * EventSource can't send headers → ?token= bearer fallback (bearer tokens only).\n * SECURITY NOTE — log-leak risk: a token carried in the query string can leak into web\n * server/proxy access logs, browser history, and (if the URL is shared/redirected) the\n * `Referer` header. This fallback only remains because of the EventSource constraint; prefer\n * @gnldev/studio's short-lived (60s TTL) one-time `POST /auth/sse-ticket` → `?ticket=` flow where\n * possible (see the `/events` endpoint in packages/studio/src/server.ts) — the persistent secret\n * is never carried in the URL.\n */\n // GET only. The constraint this exists for is EventSource, which cannot send headers and only\n // ever issues a GET — so accepting it on POST/DELETE bought nothing, and it authenticated an\n // admin retention purge from a URL, which lands in proxy logs, browser history and `Referer`.\n // The short-lived `?ticket=` flow named above is the preferred path even for the GET.\n if (req.method.toUpperCase() !== 'GET') return false;\n const q = new URL(req.url).searchParams.get('token') ?? undefined;\n return !!q && [...role.tokens].some((v) => safeEqual(q, v));\n };\n\n /**\n * Principal of the matched class: id + organization bound to the identity (Cred.orgId).\n *\n * `id` is the basic-auth `user` and nothing else — unchanged, pre-existing behavior. A bearer token\n * yields no `id`, and that absence is what tells `resolveResourceId` this deployment has no\n * per-caller subject. `credentialId` is filled separately, for every class, from `tokenId` — a\n * stable fingerprint of the credential that presented itself, for budget/admission keying only (see\n * `tokenId` and the `Principal.credentialId` doc comment for why the two must not be merged).\n *\n * `superAdmin` carries `admin` too, so every existing `roles.includes('admin')` check — here, in\n * @gnldev/server, in @gnldev/studio — keeps working without knowing the class exists; the added\n * `platform-admin` is what widens its SCOPE (scope.ts `principalScope`).\n *\n * The pre-existing `platformAdmin: true` cred flag still injects the same role on any class, so\n * configs written before `superAdmin` existed are unaffected. Prefer the class: it says which\n * credential this IS, rather than adding a privilege to one that reads as an ordinary admin.\n */\n const principalOfRole = (role: 'admin' | 'viewer' | typeof CLIENT_ROLE, cred?: Cred, platform = false): Principal => {\n const roles = role === 'admin' && platform ? ['admin', PLATFORM_ADMIN_ROLE] : [role];\n const credentialId = cred?.token ? tokenId(cred.token) : undefined;\n return {\n roles: cred?.platformAdmin && !roles.includes(PLATFORM_ADMIN_ROLE) ? [...roles, PLATFORM_ADMIN_ROLE] : roles,\n ...(cred?.user ? { id: cred.user } : {}),\n ...(credentialId ? { credentialId } : {}),\n ...(cred?.orgId ? { orgId: cred.orgId } : {}),\n };\n };\n\n return {\n authenticate(req: Request): Principal | null {\n // Most-privileged first: a token configured for two classes resolves to the stronger one, which\n // is the safe direction for `authenticate` (the weaker class would silently under-authorize a\n // credential the host declared as an operator).\n if (superAdmin.headers.size && matchRole(req, superAdmin)) return principalOfRole('admin', cfg.superAdmin, true);\n if (admin.headers.size && matchRole(req, admin)) return principalOfRole('admin', cfg.admin);\n if (client.headers.size && matchRole(req, client)) return principalOfRole(CLIENT_ROLE, cfg.client);\n if (viewer.headers.size && matchRole(req, viewer)) return principalOfRole('viewer', cfg.viewer);\n return null;\n },\n authorize(principal: Principal | null, _req: Request, ctx: AuthContext): Decision {\n const roles = principal?.roles ?? [];\n if (ctx.action === 'write') {\n if (roles.includes('admin')) return { allow: true };\n // An application credential may only perform writes this file NAMES. `ctx.permission` is\n // undefined for a route gated with the coarse `allow(req,'write')` — denied, deliberately.\n if (roles.includes(CLIENT_ROLE)) {\n return ctx.permission && CLIENT_WRITES.has(ctx.permission)\n ? { allow: true }\n : { allow: false, status: 403, reason: `unauthorized (client credentials cannot ${ctx.permission ?? 'perform this write'})` };\n }\n return { allow: false, status: 403, reason: 'unauthorized (admin required)' };\n }\n return roles.includes('admin') || roles.includes(CLIENT_ROLE) || roles.includes('viewer')\n ? { allow: true }\n : { allow: false, status: 401, reason: 'unauthorized' };\n },\n capabilities() {\n return { sso: false, rbac: false, audit: false, multiOrganization: false, users: false };\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Constant-time secret comparison: plain `===` / Set.has is exposed to a timing side channel (the
|
|
2
|
+
// further the match progresses/the earlier it bails, the longer the comparison takes → an attacker
|
|
3
|
+
// can brute-force the token character by character). node:crypto timingSafeEqual is constant-time
|
|
4
|
+
// BUT requires equal-length buffers (throws on a length mismatch → which is itself a side channel).
|
|
5
|
+
// Solution (the standard trick): hash BOTH values with sha256 before comparing — since the digest
|
|
6
|
+
// is always a fixed (32 byte) length, the original length difference doesn't leak, and timingSafeEqual
|
|
7
|
+
// makes the rest constant-time.
|
|
8
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
/** Compares two secrets (bearer token, `Authorization` header value...) in constant time. */
|
|
10
|
+
export function safeEqual(a, b) {
|
|
11
|
+
const ha = createHash('sha256').update(a, 'utf8').digest();
|
|
12
|
+
const hb = createHash('sha256').update(b, 'utf8').digest();
|
|
13
|
+
return timingSafeEqual(ha, hb);
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=safe-equal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safe-equal.js","sourceRoot":"","sources":["../src/safe-equal.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,mGAAmG;AACnG,kGAAkG;AAClG,oGAAoG;AACpG,kGAAkG;AAClG,uGAAuG;AACvG,gCAAgC;AAChC,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE1D,6FAA6F;AAC7F,MAAM,UAAU,SAAS,CAAC,CAAS,EAAE,CAAS;IAC5C,MAAM,EAAE,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;IAC3D,MAAM,EAAE,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;IAC3D,OAAO,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;AACjC,CAAC","sourcesContent":["// Constant-time secret comparison: plain `===` / Set.has is exposed to a timing side channel (the\n// further the match progresses/the earlier it bails, the longer the comparison takes → an attacker\n// can brute-force the token character by character). node:crypto timingSafeEqual is constant-time\n// BUT requires equal-length buffers (throws on a length mismatch → which is itself a side channel).\n// Solution (the standard trick): hash BOTH values with sha256 before comparing — since the digest\n// is always a fixed (32 byte) length, the original length difference doesn't leak, and timingSafeEqual\n// makes the rest constant-time.\nimport { createHash, timingSafeEqual } from 'node:crypto';\n\n/** Compares two secrets (bearer token, `Authorization` header value...) in constant time. */\nexport function safeEqual(a: string, b: string): boolean {\n const ha = createHash('sha256').update(a, 'utf8').digest();\n const hb = createHash('sha256').update(b, 'utf8').digest();\n return timingSafeEqual(ha, hb);\n}\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `true` when the request both changes state AND was initiated by a different site.
|
|
3
|
+
*
|
|
4
|
+
* Primary signal is Fetch Metadata (`Sec-Fetch-Site`), which every current browser sends and no
|
|
5
|
+
* page can forge. `same-site` is accepted rather than rejected: the documented dev setup proxies
|
|
6
|
+
* `/api` through Vite so it is same-ORIGIN anyway, and rejecting same-site would break anyone
|
|
7
|
+
* serving the UI from a sibling port for no meaningful gain — a hostile page on your own machine's
|
|
8
|
+
* other port is a different threat model from any page on the internet.
|
|
9
|
+
*
|
|
10
|
+
* When the header is absent, the request is not from a modern browser. A non-browser client is not
|
|
11
|
+
* the CSRF threat — it has no ambient credentials to be ridden — so it falls through to the `Origin`
|
|
12
|
+
* hostname, and to allow when there is no Origin either. Hostname, not origin: ports must differ
|
|
13
|
+
* freely (localhost:5173 → localhost:4747) while example.com must not.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isCrossSiteStateChange(req: Request): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Is this a state-changing request that a page on another site caused?
|
|
2
|
+
//
|
|
3
|
+
// The default posture of a new gnl project is auth-off on loopback, which is the right default for a
|
|
4
|
+
// local tool — but "open to this machine" and "open to every page this machine's browser visits" are
|
|
5
|
+
// different things, and the gate treated them as one. With no provider it returned true for every
|
|
6
|
+
// request, and nothing anywhere looked at where the request came from.
|
|
7
|
+
//
|
|
8
|
+
// Measured on the publish candidate: a cross-origin request to `POST /api/retention/sweep` purged a
|
|
9
|
+
// seeded run (RUNS before [r-1] → purged:["r-1"] → RUNS after []). `POST /agents/:name/run` passed
|
|
10
|
+
// the same gate and would bill the project's API key. It is reachable from a plain
|
|
11
|
+
// `<form enctype="text/plain">` navigation, so it needs no CORS preflight and no fetch() permission
|
|
12
|
+
// — the same shape as the webpack-dev-server and Vite dev-server advisories.
|
|
13
|
+
//
|
|
14
|
+
// SCOPE — deliberately only the providerless case. When a provider IS configured the defence is the
|
|
15
|
+
// bearer token, which a cross-site page cannot read; gnl uses no cookies, so token auth is not
|
|
16
|
+
// CSRF-reachable. Applying this with a provider present would break legitimate cross-origin API
|
|
17
|
+
// clients for no gain.
|
|
18
|
+
/** Methods that cannot change state, so cross-site is not a concern for them. */
|
|
19
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
20
|
+
/**
|
|
21
|
+
* `true` when the request both changes state AND was initiated by a different site.
|
|
22
|
+
*
|
|
23
|
+
* Primary signal is Fetch Metadata (`Sec-Fetch-Site`), which every current browser sends and no
|
|
24
|
+
* page can forge. `same-site` is accepted rather than rejected: the documented dev setup proxies
|
|
25
|
+
* `/api` through Vite so it is same-ORIGIN anyway, and rejecting same-site would break anyone
|
|
26
|
+
* serving the UI from a sibling port for no meaningful gain — a hostile page on your own machine's
|
|
27
|
+
* other port is a different threat model from any page on the internet.
|
|
28
|
+
*
|
|
29
|
+
* When the header is absent, the request is not from a modern browser. A non-browser client is not
|
|
30
|
+
* the CSRF threat — it has no ambient credentials to be ridden — so it falls through to the `Origin`
|
|
31
|
+
* hostname, and to allow when there is no Origin either. Hostname, not origin: ports must differ
|
|
32
|
+
* freely (localhost:5173 → localhost:4747) while example.com must not.
|
|
33
|
+
*/
|
|
34
|
+
export function isCrossSiteStateChange(req) {
|
|
35
|
+
if (SAFE.has(req.method.toUpperCase()))
|
|
36
|
+
return false;
|
|
37
|
+
const site = req.headers.get('sec-fetch-site');
|
|
38
|
+
if (site)
|
|
39
|
+
return site.trim().toLowerCase() === 'cross-site';
|
|
40
|
+
const origin = req.headers.get('origin');
|
|
41
|
+
if (!origin)
|
|
42
|
+
return false;
|
|
43
|
+
// `null` is an OPAQUE origin — a sandboxed iframe, a `data:` document, a `file://` page, some
|
|
44
|
+
// redirect chains. It is never the app itself, so allowing it was the wrong side of the fence: the
|
|
45
|
+
// earlier reading here was that Fetch Metadata covers those contexts, which is true of a current
|
|
46
|
+
// browser but not of a client that omits the header. An opaque origin is not same-origin by
|
|
47
|
+
// definition, so it is treated as cross-site.
|
|
48
|
+
if (origin === 'null')
|
|
49
|
+
return true;
|
|
50
|
+
try {
|
|
51
|
+
return new URL(origin).hostname !== new URL(req.url).hostname;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return true; // an Origin that will not parse is not one to trust
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=same-site.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"same-site.js","sourceRoot":"","sources":["../src/same-site.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,EAAE;AACF,qGAAqG;AACrG,qGAAqG;AACrG,kGAAkG;AAClG,uEAAuE;AACvE,EAAE;AACF,oGAAoG;AACpG,mGAAmG;AACnG,mFAAmF;AACnF,oGAAoG;AACpG,6EAA6E;AAC7E,EAAE;AACF,oGAAoG;AACpG,+FAA+F;AAC/F,gGAAgG;AAChG,uBAAuB;AAEvB,iFAAiF;AACjF,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC;AAEjD;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,sBAAsB,CAAC,GAAY;IACjD,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;QAAE,OAAO,KAAK,CAAC;IAErD,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAC/C,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,YAAY,CAAC;IAE5D,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC1B,8FAA8F;IAC9F,mGAAmG;IACnG,iGAAiG;IACjG,4FAA4F;IAC5F,8CAA8C;IAC9C,IAAI,MAAM,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,QAAQ,KAAK,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC;IAChE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC,CAAC,oDAAoD;IACnE,CAAC;AACH,CAAC","sourcesContent":["// Is this a state-changing request that a page on another site caused?\n//\n// The default posture of a new gnl project is auth-off on loopback, which is the right default for a\n// local tool — but \"open to this machine\" and \"open to every page this machine's browser visits\" are\n// different things, and the gate treated them as one. With no provider it returned true for every\n// request, and nothing anywhere looked at where the request came from.\n//\n// Measured on the publish candidate: a cross-origin request to `POST /api/retention/sweep` purged a\n// seeded run (RUNS before [r-1] → purged:[\"r-1\"] → RUNS after []). `POST /agents/:name/run` passed\n// the same gate and would bill the project's API key. It is reachable from a plain\n// `<form enctype=\"text/plain\">` navigation, so it needs no CORS preflight and no fetch() permission\n// — the same shape as the webpack-dev-server and Vite dev-server advisories.\n//\n// SCOPE — deliberately only the providerless case. When a provider IS configured the defence is the\n// bearer token, which a cross-site page cannot read; gnl uses no cookies, so token auth is not\n// CSRF-reachable. Applying this with a provider present would break legitimate cross-origin API\n// clients for no gain.\n\n/** Methods that cannot change state, so cross-site is not a concern for them. */\nconst SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);\n\n/**\n * `true` when the request both changes state AND was initiated by a different site.\n *\n * Primary signal is Fetch Metadata (`Sec-Fetch-Site`), which every current browser sends and no\n * page can forge. `same-site` is accepted rather than rejected: the documented dev setup proxies\n * `/api` through Vite so it is same-ORIGIN anyway, and rejecting same-site would break anyone\n * serving the UI from a sibling port for no meaningful gain — a hostile page on your own machine's\n * other port is a different threat model from any page on the internet.\n *\n * When the header is absent, the request is not from a modern browser. A non-browser client is not\n * the CSRF threat — it has no ambient credentials to be ridden — so it falls through to the `Origin`\n * hostname, and to allow when there is no Origin either. Hostname, not origin: ports must differ\n * freely (localhost:5173 → localhost:4747) while example.com must not.\n */\nexport function isCrossSiteStateChange(req: Request): boolean {\n if (SAFE.has(req.method.toUpperCase())) return false;\n\n const site = req.headers.get('sec-fetch-site');\n if (site) return site.trim().toLowerCase() === 'cross-site';\n\n const origin = req.headers.get('origin');\n if (!origin) return false;\n // `null` is an OPAQUE origin — a sandboxed iframe, a `data:` document, a `file://` page, some\n // redirect chains. It is never the app itself, so allowing it was the wrong side of the fence: the\n // earlier reading here was that Fetch Metadata covers those contexts, which is true of a current\n // browser but not of a client that omits the header. An opaque origin is not same-origin by\n // definition, so it is treated as cross-site.\n if (origin === 'null') return true;\n try {\n return new URL(origin).hostname !== new URL(req.url).hostname;\n } catch {\n return true; // an Origin that will not parse is not one to trust\n }\n}\n"]}
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Principal } from './types.js';
|
|
2
|
+
/** The reserved role that grants PLATFORM scope (sees/manages every organization). */
|
|
3
|
+
export declare const PLATFORM_ADMIN_ROLE = "platform-admin";
|
|
4
|
+
/**
|
|
5
|
+
* True if the principal carries the EXPLICIT platform-admin grant (the `platform-admin` role).
|
|
6
|
+
* Being unbound (no `orgId`) alone is NOT enough — that is exactly the accidental-super-admin bug
|
|
7
|
+
* the strict model closes.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isPlatformAdmin(principal: Principal | null | undefined): boolean;
|
|
10
|
+
/** The resolved SCOPE of an identity (the "where", independent of the role/"what"). */
|
|
11
|
+
export type PrincipalScope =
|
|
12
|
+
/** Sees/manages every organization (explicit platform-admin grant). */
|
|
13
|
+
{
|
|
14
|
+
kind: 'platform';
|
|
15
|
+
}
|
|
16
|
+
/** Bound to exactly one organization. */
|
|
17
|
+
| {
|
|
18
|
+
kind: 'org';
|
|
19
|
+
orgId: string;
|
|
20
|
+
}
|
|
21
|
+
/** No organization AND no platform grant → the strict model denies (fail-closed). */
|
|
22
|
+
| {
|
|
23
|
+
kind: 'none';
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Pure scope derivation. Precedence: an EXPLICIT platform grant wins over an org binding (a
|
|
27
|
+
* platform-admin is intentionally cross-org); otherwise a bound `orgId` gives org scope; otherwise
|
|
28
|
+
* `none` (which the strict EE model rejects, and the free model treats as the legacy operator).
|
|
29
|
+
*/
|
|
30
|
+
export declare function principalScope(principal: Principal | null | undefined): PrincipalScope;
|
|
31
|
+
/** Outcome of a privilege-ceiling check (see {@link assertAssignablePrivileges}). */
|
|
32
|
+
export type AssignabilityResult = {
|
|
33
|
+
ok: true;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
reason: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* PRIVILEGE CEILING for user-management (create/update). An assigner must NEVER be able to hand out a
|
|
40
|
+
* privilege it does not itself hold — otherwise an org-bound admin could mint itself (or a new user)
|
|
41
|
+
* the reserved `platform-admin` role and walk out of its own org as a cross-org super-admin. The
|
|
42
|
+
* user-management surfaces (@gnldev/studio POST/PATCH /users) validate only the target's ORG, not the
|
|
43
|
+
* ROLE/PERMISSION VALUES; this closes that gap.
|
|
44
|
+
*
|
|
45
|
+
* Rule (deliberately minimal + scope-aware): a platform-admin may assign anything. Anyone else may
|
|
46
|
+
* NOT grant the `platform-admin` role (a cross-org SCOPE escalation) nor the `'*'` all-permissions
|
|
47
|
+
* grant (its permission-axis equivalent). Ordinary org roles/permissions stay inside the assigner's
|
|
48
|
+
* org (the target keeps its `orgId`), so they are not a cross-org escalation and remain assignable.
|
|
49
|
+
*/
|
|
50
|
+
export declare function assertAssignablePrivileges(assigner: Principal | null | undefined, requested: {
|
|
51
|
+
roles?: string[] | undefined;
|
|
52
|
+
permissions?: string[] | undefined;
|
|
53
|
+
}): AssignabilityResult;
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The reserved role that grants PLATFORM scope (sees/manages every organization). */
|
|
2
|
+
export const PLATFORM_ADMIN_ROLE = 'platform-admin';
|
|
3
|
+
/**
|
|
4
|
+
* True if the principal carries the EXPLICIT platform-admin grant (the `platform-admin` role).
|
|
5
|
+
* Being unbound (no `orgId`) alone is NOT enough — that is exactly the accidental-super-admin bug
|
|
6
|
+
* the strict model closes.
|
|
7
|
+
*/
|
|
8
|
+
export function isPlatformAdmin(principal) {
|
|
9
|
+
return !!principal?.roles?.includes(PLATFORM_ADMIN_ROLE);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Pure scope derivation. Precedence: an EXPLICIT platform grant wins over an org binding (a
|
|
13
|
+
* platform-admin is intentionally cross-org); otherwise a bound `orgId` gives org scope; otherwise
|
|
14
|
+
* `none` (which the strict EE model rejects, and the free model treats as the legacy operator).
|
|
15
|
+
*/
|
|
16
|
+
export function principalScope(principal) {
|
|
17
|
+
if (isPlatformAdmin(principal))
|
|
18
|
+
return { kind: 'platform' };
|
|
19
|
+
const orgId = principal?.orgId;
|
|
20
|
+
if (orgId)
|
|
21
|
+
return { kind: 'org', orgId };
|
|
22
|
+
return { kind: 'none' };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* PRIVILEGE CEILING for user-management (create/update). An assigner must NEVER be able to hand out a
|
|
26
|
+
* privilege it does not itself hold — otherwise an org-bound admin could mint itself (or a new user)
|
|
27
|
+
* the reserved `platform-admin` role and walk out of its own org as a cross-org super-admin. The
|
|
28
|
+
* user-management surfaces (@gnldev/studio POST/PATCH /users) validate only the target's ORG, not the
|
|
29
|
+
* ROLE/PERMISSION VALUES; this closes that gap.
|
|
30
|
+
*
|
|
31
|
+
* Rule (deliberately minimal + scope-aware): a platform-admin may assign anything. Anyone else may
|
|
32
|
+
* NOT grant the `platform-admin` role (a cross-org SCOPE escalation) nor the `'*'` all-permissions
|
|
33
|
+
* grant (its permission-axis equivalent). Ordinary org roles/permissions stay inside the assigner's
|
|
34
|
+
* org (the target keeps its `orgId`), so they are not a cross-org escalation and remain assignable.
|
|
35
|
+
*/
|
|
36
|
+
export function assertAssignablePrivileges(assigner, requested) {
|
|
37
|
+
// A platform-admin is already cross-org: it can assign any role/permission.
|
|
38
|
+
if (isPlatformAdmin(assigner))
|
|
39
|
+
return { ok: true };
|
|
40
|
+
// The reserved cross-org grant — never mintable by a non-platform-admin.
|
|
41
|
+
if (requested.roles?.includes(PLATFORM_ADMIN_ROLE)) {
|
|
42
|
+
return { ok: false, reason: `only a platform-admin can grant the '${PLATFORM_ADMIN_ROLE}' role` };
|
|
43
|
+
}
|
|
44
|
+
// The all-permissions super-grant on the permission axis — same escalation by another name.
|
|
45
|
+
if (requested.permissions?.includes('*')) {
|
|
46
|
+
return { ok: false, reason: "only a platform-admin can grant the '*' (all-permissions) grant" };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=scope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope.js","sourceRoot":"","sources":["../src/scope.ts"],"names":[],"mappings":"AAaA,sFAAsF;AACtF,MAAM,CAAC,MAAM,mBAAmB,GAAG,gBAAgB,CAAC;AAEpD;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,SAAuC;IACrE,OAAO,CAAC,CAAC,SAAS,EAAE,KAAK,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAC;AAC3D,CAAC;AAWD;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,SAAuC;IACpE,IAAI,eAAe,CAAC,SAAS,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC;IAC5D,MAAM,KAAK,GAAG,SAAS,EAAE,KAAK,CAAC;IAC/B,IAAI,KAAK;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACzC,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;AAC1B,CAAC;AAKD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,0BAA0B,CACxC,QAAsC,EACtC,SAA+E;IAE/E,4EAA4E;IAC5E,IAAI,eAAe,CAAC,QAAQ,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;IACnD,yEAAyE;IACzE,IAAI,SAAS,CAAC,KAAK,EAAE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,CAAC;QACnD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,wCAAwC,mBAAmB,QAAQ,EAAE,CAAC;IACpG,CAAC;IACD,4FAA4F;IAC5F,IAAI,SAAS,CAAC,WAAW,EAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QACzC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iEAAiE,EAAE,CAAC;IAClG,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACtB,CAAC","sourcesContent":["// Authorization AXES (kept deliberately SEPARATE):\n// • SCOPE — WHERE an identity can act: a single organization (`org:<id>`) OR the whole `platform`.\n// • ROLE — WHAT it can do: viewer(read) / member(run) / admin(manage) — the existing `roles[]`.\n//\n// The platform scope is an EXPLICIT grant, expressed with the reserved `platform-admin` role. It is\n// NEVER derived from \"the identity happens to have no orgId\" — that inference is the classic\n// fail-OPEN footgun (forgetting an orgId accidentally minted a super-admin). Hosts that run the strict\n// (paid/EE multi-org) model treat an unbound identity WITHOUT this grant as fail-CLOSED (no access).\n//\n// This module is pure (no host/Hono coupling) → unit-testable in isolation and reusable by\n// @gnldev/server, @gnldev/studio and @gnldev/auth-ee.\nimport type { Principal } from './types.js';\n\n/** The reserved role that grants PLATFORM scope (sees/manages every organization). */\nexport const PLATFORM_ADMIN_ROLE = 'platform-admin';\n\n/**\n * True if the principal carries the EXPLICIT platform-admin grant (the `platform-admin` role).\n * Being unbound (no `orgId`) alone is NOT enough — that is exactly the accidental-super-admin bug\n * the strict model closes.\n */\nexport function isPlatformAdmin(principal: Principal | null | undefined): boolean {\n return !!principal?.roles?.includes(PLATFORM_ADMIN_ROLE);\n}\n\n/** The resolved SCOPE of an identity (the \"where\", independent of the role/\"what\"). */\nexport type PrincipalScope =\n /** Sees/manages every organization (explicit platform-admin grant). */\n | { kind: 'platform' }\n /** Bound to exactly one organization. */\n | { kind: 'org'; orgId: string }\n /** No organization AND no platform grant → the strict model denies (fail-closed). */\n | { kind: 'none' };\n\n/**\n * Pure scope derivation. Precedence: an EXPLICIT platform grant wins over an org binding (a\n * platform-admin is intentionally cross-org); otherwise a bound `orgId` gives org scope; otherwise\n * `none` (which the strict EE model rejects, and the free model treats as the legacy operator).\n */\nexport function principalScope(principal: Principal | null | undefined): PrincipalScope {\n if (isPlatformAdmin(principal)) return { kind: 'platform' };\n const orgId = principal?.orgId;\n if (orgId) return { kind: 'org', orgId };\n return { kind: 'none' };\n}\n\n/** Outcome of a privilege-ceiling check (see {@link assertAssignablePrivileges}). */\nexport type AssignabilityResult = { ok: true } | { ok: false; reason: string };\n\n/**\n * PRIVILEGE CEILING for user-management (create/update). An assigner must NEVER be able to hand out a\n * privilege it does not itself hold — otherwise an org-bound admin could mint itself (or a new user)\n * the reserved `platform-admin` role and walk out of its own org as a cross-org super-admin. The\n * user-management surfaces (@gnldev/studio POST/PATCH /users) validate only the target's ORG, not the\n * ROLE/PERMISSION VALUES; this closes that gap.\n *\n * Rule (deliberately minimal + scope-aware): a platform-admin may assign anything. Anyone else may\n * NOT grant the `platform-admin` role (a cross-org SCOPE escalation) nor the `'*'` all-permissions\n * grant (its permission-axis equivalent). Ordinary org roles/permissions stay inside the assigner's\n * org (the target keeps its `orgId`), so they are not a cross-org escalation and remain assignable.\n */\nexport function assertAssignablePrivileges(\n assigner: Principal | null | undefined,\n requested: { roles?: string[] | undefined; permissions?: string[] | undefined },\n): AssignabilityResult {\n // A platform-admin is already cross-org: it can assign any role/permission.\n if (isPlatformAdmin(assigner)) return { ok: true };\n // The reserved cross-org grant — never mintable by a non-platform-admin.\n if (requested.roles?.includes(PLATFORM_ADMIN_ROLE)) {\n return { ok: false, reason: `only a platform-admin can grant the '${PLATFORM_ADMIN_ROLE}' role` };\n }\n // The all-permissions super-grant on the permission axis — same escalation by another name.\n if (requested.permissions?.includes('*')) {\n return { ok: false, reason: \"only a platform-admin can grant the '*' (all-permissions) grant\" };\n }\n return { ok: true };\n}\n"]}
|