@vantageos/cloud-identity 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -1
- package/LICENSE +105 -16
- package/README.md +183 -53
- package/dist/bearer-validation.d.ts +2 -3
- package/dist/bearer-validation.d.ts.map +1 -1
- package/dist/bearer-validation.js +2 -3
- package/dist/bearer-validation.js.map +1 -1
- package/dist/crypto.d.ts +3 -5
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +3 -5
- package/dist/crypto.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/org-guard.d.ts +79 -0
- package/dist/org-guard.d.ts.map +1 -0
- package/dist/org-guard.js +70 -0
- package/dist/org-guard.js.map +1 -0
- package/dist/scope-filter.d.ts +33 -18
- package/dist/scope-filter.d.ts.map +1 -1
- package/dist/scope-filter.js +44 -19
- package/dist/scope-filter.js.map +1 -1
- package/dist/tenancy-domain.d.ts +127 -0
- package/dist/tenancy-domain.d.ts.map +1 -0
- package/dist/tenancy-domain.js +148 -0
- package/dist/tenancy-domain.js.map +1 -0
- package/package.json +9 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vantageos/cloud-identity — Organization-membership guard.
|
|
3
|
+
* (See package.json "version" for the release this shipped in.)
|
|
4
|
+
*
|
|
5
|
+
* Closes the second measured 0.2.0 defect: the published package exposed no
|
|
6
|
+
* "must belong to an organization" gate anywhere (`grep -rlE
|
|
7
|
+
* "requireOrg|orgRequired|orgId" package/dist/*.js` returned no matches on
|
|
8
|
+
* the 0.2.0 tarball). Absence of an organization must REFUSE, never fall
|
|
9
|
+
* through to full access — the same defect class as the scope-filter fix in
|
|
10
|
+
* `./scope-filter.ts`.
|
|
11
|
+
*
|
|
12
|
+
* The semantics are reused verbatim from a Convex implementation of the same
|
|
13
|
+
* guard rather than reinvented — a second implementation of one authorization
|
|
14
|
+
* check is a second chance to get it wrong. That original:
|
|
15
|
+
*
|
|
16
|
+
* - takes a Convex `QueryCtx | MutationCtx | ActionCtx`
|
|
17
|
+
* - derives identity via `ctx.auth.getUserIdentity()`
|
|
18
|
+
* - throws when there is no session
|
|
19
|
+
* - throws when `identity.org_id` is not a non-empty string
|
|
20
|
+
* - returns the org id
|
|
21
|
+
*
|
|
22
|
+
* ADAPTATION (named per the brief's requirement to flag any semantic
|
|
23
|
+
* deviation): this package is framework-agnostic and has no `ctx.auth`. The
|
|
24
|
+
* caller pre-resolves whichever identity/bearer object it already has
|
|
25
|
+
* (Clerk session identity for the human path, or the package's own
|
|
26
|
+
* `TenantContext` — see `./tenancy-domain.ts` — for the machine/bearer path)
|
|
27
|
+
* and passes it in as a `TenantSource`. The refuse-on-absence /
|
|
28
|
+
* refuse-on-empty semantics are otherwise identical to the source function;
|
|
29
|
+
* no other behavioural change was made.
|
|
30
|
+
*
|
|
31
|
+
* This is the membership half of the package's contract; tenant resolution,
|
|
32
|
+
* row filtering and identifier validation are covered by
|
|
33
|
+
* `getEffectiveTenantId`, `passesScopeFilter`/`scopeFilterList` and
|
|
34
|
+
* `validateMasterBearer` respectively. Framework adapters wrap
|
|
35
|
+
* `requireTenantId`; they must never reimplement its checks.
|
|
36
|
+
*/
|
|
37
|
+
import type { TenantContext } from "./tenancy-domain.js";
|
|
38
|
+
/**
|
|
39
|
+
* Minimal shape of a resolved human-path session identity. Intentionally
|
|
40
|
+
* framework-agnostic: the caller derives this from Clerk (or any other
|
|
41
|
+
* session provider) before calling `requireTenantId` — this package never
|
|
42
|
+
* imports a session SDK.
|
|
43
|
+
*/
|
|
44
|
+
export type SessionIdentity = {
|
|
45
|
+
orgId?: string | null;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Discriminated union covering both VantagePeers Cloud entry paths behind
|
|
49
|
+
* one contract:
|
|
50
|
+
* - `session`: human path, via a connection/login provider (Clerk, etc.).
|
|
51
|
+
* - `bearer`: machine path, via a resolved, ALREADY-VERIFIED token
|
|
52
|
+
* (`TenantContext` — see `decodeUnverifiedBearer` / the planned signed-JWT
|
|
53
|
+
* verifier for how a `TenantContext` gets produced).
|
|
54
|
+
*/
|
|
55
|
+
export type TenantSource = {
|
|
56
|
+
kind: "session";
|
|
57
|
+
identity: SessionIdentity | null | undefined;
|
|
58
|
+
} | {
|
|
59
|
+
kind: "bearer";
|
|
60
|
+
context: TenantContext;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the caller's tenant id, refusing when no organization/workspace is
|
|
64
|
+
* attached — for EITHER entry path, behind one contract.
|
|
65
|
+
*
|
|
66
|
+
* - `kind: "session"` and `identity` is null/undefined → throws
|
|
67
|
+
* ("Unauthenticated: no session.")
|
|
68
|
+
* - `kind: "session"` and `identity.orgId` is missing or empty → throws
|
|
69
|
+
* ("No active organization on this session...")
|
|
70
|
+
* - `kind: "bearer"` and `context.workspaceId` is missing or empty → throws
|
|
71
|
+
* ("No workspace on this bearer-resolved tenant context...")
|
|
72
|
+
* - otherwise → returns the tenant id (orgId, or workspaceId)
|
|
73
|
+
*
|
|
74
|
+
* A right is never granted by absence: every branch above refuses by
|
|
75
|
+
* throwing, there is no fallback branch that returns a value when
|
|
76
|
+
* organization/workspace information is missing.
|
|
77
|
+
*/
|
|
78
|
+
export declare function requireTenantId(source: TenantSource): string;
|
|
79
|
+
//# sourceMappingURL=org-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"org-guard.d.ts","sourceRoot":"","sources":["../src/org-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,eAAe,GAAG,IAAI,GAAG,SAAS,CAAA;CAAE,GACjE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,aAAa,CAAA;CAAE,CAAC;AAE/C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAqB5D"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vantageos/cloud-identity — Organization-membership guard.
|
|
3
|
+
* (See package.json "version" for the release this shipped in.)
|
|
4
|
+
*
|
|
5
|
+
* Closes the second measured 0.2.0 defect: the published package exposed no
|
|
6
|
+
* "must belong to an organization" gate anywhere (`grep -rlE
|
|
7
|
+
* "requireOrg|orgRequired|orgId" package/dist/*.js` returned no matches on
|
|
8
|
+
* the 0.2.0 tarball). Absence of an organization must REFUSE, never fall
|
|
9
|
+
* through to full access — the same defect class as the scope-filter fix in
|
|
10
|
+
* `./scope-filter.ts`.
|
|
11
|
+
*
|
|
12
|
+
* The semantics are reused verbatim from a Convex implementation of the same
|
|
13
|
+
* guard rather than reinvented — a second implementation of one authorization
|
|
14
|
+
* check is a second chance to get it wrong. That original:
|
|
15
|
+
*
|
|
16
|
+
* - takes a Convex `QueryCtx | MutationCtx | ActionCtx`
|
|
17
|
+
* - derives identity via `ctx.auth.getUserIdentity()`
|
|
18
|
+
* - throws when there is no session
|
|
19
|
+
* - throws when `identity.org_id` is not a non-empty string
|
|
20
|
+
* - returns the org id
|
|
21
|
+
*
|
|
22
|
+
* ADAPTATION (named per the brief's requirement to flag any semantic
|
|
23
|
+
* deviation): this package is framework-agnostic and has no `ctx.auth`. The
|
|
24
|
+
* caller pre-resolves whichever identity/bearer object it already has
|
|
25
|
+
* (Clerk session identity for the human path, or the package's own
|
|
26
|
+
* `TenantContext` — see `./tenancy-domain.ts` — for the machine/bearer path)
|
|
27
|
+
* and passes it in as a `TenantSource`. The refuse-on-absence /
|
|
28
|
+
* refuse-on-empty semantics are otherwise identical to the source function;
|
|
29
|
+
* no other behavioural change was made.
|
|
30
|
+
*
|
|
31
|
+
* This is the membership half of the package's contract; tenant resolution,
|
|
32
|
+
* row filtering and identifier validation are covered by
|
|
33
|
+
* `getEffectiveTenantId`, `passesScopeFilter`/`scopeFilterList` and
|
|
34
|
+
* `validateMasterBearer` respectively. Framework adapters wrap
|
|
35
|
+
* `requireTenantId`; they must never reimplement its checks.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the caller's tenant id, refusing when no organization/workspace is
|
|
39
|
+
* attached — for EITHER entry path, behind one contract.
|
|
40
|
+
*
|
|
41
|
+
* - `kind: "session"` and `identity` is null/undefined → throws
|
|
42
|
+
* ("Unauthenticated: no session.")
|
|
43
|
+
* - `kind: "session"` and `identity.orgId` is missing or empty → throws
|
|
44
|
+
* ("No active organization on this session...")
|
|
45
|
+
* - `kind: "bearer"` and `context.workspaceId` is missing or empty → throws
|
|
46
|
+
* ("No workspace on this bearer-resolved tenant context...")
|
|
47
|
+
* - otherwise → returns the tenant id (orgId, or workspaceId)
|
|
48
|
+
*
|
|
49
|
+
* A right is never granted by absence: every branch above refuses by
|
|
50
|
+
* throwing, there is no fallback branch that returns a value when
|
|
51
|
+
* organization/workspace information is missing.
|
|
52
|
+
*/
|
|
53
|
+
export function requireTenantId(source) {
|
|
54
|
+
if (source.kind === "session") {
|
|
55
|
+
if (!source.identity) {
|
|
56
|
+
throw new Error("Unauthenticated: no session.");
|
|
57
|
+
}
|
|
58
|
+
const orgId = source.identity.orgId;
|
|
59
|
+
if (typeof orgId !== "string" || orgId.length === 0) {
|
|
60
|
+
throw new Error("No active organization on this session: select an organization before accessing agents.");
|
|
61
|
+
}
|
|
62
|
+
return orgId;
|
|
63
|
+
}
|
|
64
|
+
const workspaceId = source.context?.workspaceId;
|
|
65
|
+
if (typeof workspaceId !== "string" || workspaceId.length === 0) {
|
|
66
|
+
throw new Error("No workspace on this bearer-resolved tenant context: the bearer must resolve to a non-empty workspaceId.");
|
|
67
|
+
}
|
|
68
|
+
return workspaceId;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=org-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"org-guard.js","sourceRoot":"","sources":["../src/org-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AA0BH;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,eAAe,CAAC,MAAoB;IAClD,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC9B,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;YACrB,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAClD,CAAC;QACD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QACpC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACpD,MAAM,IAAI,KAAK,CACb,yFAAyF,CAC1F,CAAC;QACJ,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,WAAW,GAAG,MAAM,CAAC,OAAO,EAAE,WAAW,CAAC;IAChD,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAChE,MAAM,IAAI,KAAK,CACb,0GAA0G,CAC3G,CAAC;IACJ,CAAC;IACD,OAAO,WAAW,CAAC;AACrB,CAAC"}
|
package/dist/scope-filter.d.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Scope-aware row filter.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The behaviour was preserved verbatim from an earlier in-tree
|
|
5
|
+
* implementation; the only change was replacing a framework-specific context
|
|
6
|
+
* type with the framework-agnostic `OAuthCtx` from `./types`.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* Contract (0.3.0 — BREAKING vs. 0.2.0):
|
|
9
|
+
* - `oauthCtx` is now MANDATORY. A right is never granted by absence: the
|
|
10
|
+
* 0.2.0 behaviour of `oauthCtx === undefined` silently passing everything
|
|
11
|
+
* (as if the caller were master-scoped) was the exact defect class this
|
|
12
|
+
* release closes. Every exported function below now requires `OAuthCtx`
|
|
13
|
+
* at the type level — an omitted argument breaks the caller's build,
|
|
14
|
+
* it never falls through to a more permissive runtime path.
|
|
15
|
+
* - Callers that genuinely need the old legacy-bearer wildcard behaviour
|
|
16
|
+
* MUST import and pass `LEGACY_WILDCARD_CTX` explicitly — the compat
|
|
17
|
+
* path is requested by name, never inferred from a missing argument.
|
|
18
|
+
* - As defense-in-depth against callers that bypass the type system
|
|
19
|
+
* (untyped JS, `as any`), every function also throws at runtime if
|
|
20
|
+
* `oauthCtx` is nullish, instead of silently granting access.
|
|
13
21
|
* - Master scope (scope === "master" OR fromAllowList includes "*") = wildcard pass.
|
|
14
|
-
* - Legacy bearer (oauthCtx === undefined) = wildcard pass (treated as master-
|
|
15
|
-
* equivalent for backward-compatibility with mcpTenants Pi/Tau/Phi paths).
|
|
16
22
|
* - Non-master scope = row passes iff:
|
|
17
23
|
* row.createdBy ∈ oauthCtx.fromAllowList
|
|
18
24
|
* OR
|
|
@@ -24,6 +30,12 @@
|
|
|
24
30
|
* imports — to keep the helpers trivially unit-testable.
|
|
25
31
|
*/
|
|
26
32
|
import type { OAuthCtx } from "./types.js";
|
|
33
|
+
/**
|
|
34
|
+
* Explicit legacy-compat sentinel. Passing this constant reproduces the
|
|
35
|
+
* pre-0.3.0 "no oauthCtx" wildcard behaviour — but the caller must import
|
|
36
|
+
* and pass it BY NAME. It is never inferred from an omitted argument.
|
|
37
|
+
*/
|
|
38
|
+
export declare const LEGACY_WILDCARD_CTX: OAuthCtx;
|
|
27
39
|
/**
|
|
28
40
|
* Row shape accepted by the scope filter. All fields optional because real
|
|
29
41
|
* Convex documents from list_peers / list_messages / etc. don't all carry both.
|
|
@@ -37,35 +49,38 @@ export type ScopeFilterable = {
|
|
|
37
49
|
* Mirrors `isMasterScope` from the source module: `scope === "master"` OR
|
|
38
50
|
* `fromAllowList` contains the `"*"` sentinel.
|
|
39
51
|
*/
|
|
40
|
-
export declare function isMasterScope(ctx: OAuthCtx
|
|
52
|
+
export declare function isMasterScope(ctx: OAuthCtx): boolean;
|
|
41
53
|
/**
|
|
42
54
|
* Core predicate. Returns true when the row is visible to the caller.
|
|
43
55
|
*
|
|
44
56
|
* - Master scope (isMasterScope === true) → true (wildcard)
|
|
45
|
-
* - Legacy bearer (oauthCtx === undefined) → true (back-compat)
|
|
46
57
|
* - row.createdBy ∈ oauthCtx.fromAllowList → true
|
|
47
58
|
* - row.namespace === prefix OR startsWith prefix+'/'
|
|
48
59
|
* for any prefix ∈ oauthCtx.namespaceReadPrefixes → true
|
|
49
60
|
* - otherwise → false
|
|
50
61
|
*
|
|
62
|
+
* `oauthCtx` is mandatory (see module-level contract note above): an omitted
|
|
63
|
+
* argument throws instead of silently granting wildcard access. Callers that
|
|
64
|
+
* need the old legacy-bearer wildcard behaviour must pass `LEGACY_WILDCARD_CTX`
|
|
65
|
+
* explicitly.
|
|
66
|
+
*
|
|
51
67
|
* Substring matches that don't fall on a '/' boundary are explicitly rejected
|
|
52
68
|
* (e.g. namespace="orchestrator/alphabet" does NOT match prefix
|
|
53
69
|
* "orchestrator/alpha"). This avoids the classic prefix-isolation bypass.
|
|
54
70
|
*/
|
|
55
|
-
export declare function passesScopeFilter(oauthCtx: OAuthCtx
|
|
71
|
+
export declare function passesScopeFilter(oauthCtx: OAuthCtx, row: ScopeFilterable): boolean;
|
|
56
72
|
/**
|
|
57
73
|
* Filter a list of rows (post-query). Used by list_* tools.
|
|
58
74
|
*/
|
|
59
|
-
export declare function scopeFilterList<T extends ScopeFilterable>(oauthCtx: OAuthCtx
|
|
75
|
+
export declare function scopeFilterList<T extends ScopeFilterable>(oauthCtx: OAuthCtx, rows: T[]): T[];
|
|
60
76
|
/**
|
|
61
77
|
* Assert a single row passes (get_* tools). Returns the row when allowed, null
|
|
62
78
|
* otherwise — callers translate null to a 404-equivalent "not found" MCP error
|
|
63
79
|
* to avoid leaking the difference between "absent" and "filtered out".
|
|
64
80
|
*/
|
|
65
|
-
export declare function scopeFilterGet<T extends ScopeFilterable>(oauthCtx: OAuthCtx
|
|
81
|
+
export declare function scopeFilterGet<T extends ScopeFilterable>(oauthCtx: OAuthCtx, row: T | null | undefined): T | null;
|
|
66
82
|
/**
|
|
67
83
|
* Helper for callers that want a single "is master / legacy" predicate.
|
|
68
|
-
* Mirrors the legacy-bearer-passes-through convention.
|
|
69
84
|
*/
|
|
70
|
-
export declare function isWildcardScope(ctx: OAuthCtx
|
|
85
|
+
export declare function isWildcardScope(ctx: OAuthCtx): boolean;
|
|
71
86
|
//# sourceMappingURL=scope-filter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope-filter.d.ts","sourceRoot":"","sources":["../src/scope-filter.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"scope-filter.d.ts","sourceRoot":"","sources":["../src/scope-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,EAAE,QAKjC,CAAC;AAWF;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAIpD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,QAAQ,EAClB,GAAG,EAAE,eAAe,GACnB,OAAO,CAYT;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,eAAe,EACvD,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,CAAC,EAAE,GACR,CAAC,EAAE,CAGL;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,eAAe,EACtD,QAAQ,EAAE,QAAQ,EAClB,GAAG,EAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GACxB,CAAC,GAAG,IAAI,CAIV;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAGtD"}
|
package/dist/scope-filter.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Scope-aware row filter.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The behaviour was preserved verbatim from an earlier in-tree
|
|
5
|
+
* implementation; the only change was replacing a framework-specific context
|
|
6
|
+
* type with the framework-agnostic `OAuthCtx` from `./types`.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* Contract (0.3.0 — BREAKING vs. 0.2.0):
|
|
9
|
+
* - `oauthCtx` is now MANDATORY. A right is never granted by absence: the
|
|
10
|
+
* 0.2.0 behaviour of `oauthCtx === undefined` silently passing everything
|
|
11
|
+
* (as if the caller were master-scoped) was the exact defect class this
|
|
12
|
+
* release closes. Every exported function below now requires `OAuthCtx`
|
|
13
|
+
* at the type level — an omitted argument breaks the caller's build,
|
|
14
|
+
* it never falls through to a more permissive runtime path.
|
|
15
|
+
* - Callers that genuinely need the old legacy-bearer wildcard behaviour
|
|
16
|
+
* MUST import and pass `LEGACY_WILDCARD_CTX` explicitly — the compat
|
|
17
|
+
* path is requested by name, never inferred from a missing argument.
|
|
18
|
+
* - As defense-in-depth against callers that bypass the type system
|
|
19
|
+
* (untyped JS, `as any`), every function also throws at runtime if
|
|
20
|
+
* `oauthCtx` is nullish, instead of silently granting access.
|
|
13
21
|
* - Master scope (scope === "master" OR fromAllowList includes "*") = wildcard pass.
|
|
14
|
-
* - Legacy bearer (oauthCtx === undefined) = wildcard pass (treated as master-
|
|
15
|
-
* equivalent for backward-compatibility with mcpTenants Pi/Tau/Phi paths).
|
|
16
22
|
* - Non-master scope = row passes iff:
|
|
17
23
|
* row.createdBy ∈ oauthCtx.fromAllowList
|
|
18
24
|
* OR
|
|
@@ -23,14 +29,30 @@
|
|
|
23
29
|
* NOTE: this module is intentionally framework-agnostic — no McpServer / Hono
|
|
24
30
|
* imports — to keep the helpers trivially unit-testable.
|
|
25
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* Explicit legacy-compat sentinel. Passing this constant reproduces the
|
|
34
|
+
* pre-0.3.0 "no oauthCtx" wildcard behaviour — but the caller must import
|
|
35
|
+
* and pass it BY NAME. It is never inferred from an omitted argument.
|
|
36
|
+
*/
|
|
37
|
+
export const LEGACY_WILDCARD_CTX = {
|
|
38
|
+
scope: "master",
|
|
39
|
+
fromAllowList: ["*"],
|
|
40
|
+
namespaceReadPrefixes: [],
|
|
41
|
+
namespaceWritePrefixes: [],
|
|
42
|
+
};
|
|
43
|
+
function assertOauthCtx(oauthCtx) {
|
|
44
|
+
if (oauthCtx == null) {
|
|
45
|
+
throw new TypeError("oauthCtx is required — pass an explicit OAuthCtx, or LEGACY_WILDCARD_CTX " +
|
|
46
|
+
"if you deliberately want the pre-0.3.0 legacy-bearer wildcard behaviour.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
26
49
|
/**
|
|
27
50
|
* Returns true when the scope profile grants full, wildcard access.
|
|
28
51
|
* Mirrors `isMasterScope` from the source module: `scope === "master"` OR
|
|
29
52
|
* `fromAllowList` contains the `"*"` sentinel.
|
|
30
53
|
*/
|
|
31
54
|
export function isMasterScope(ctx) {
|
|
32
|
-
|
|
33
|
-
return false;
|
|
55
|
+
assertOauthCtx(ctx);
|
|
34
56
|
if (ctx.scope === "master")
|
|
35
57
|
return true;
|
|
36
58
|
return ctx.fromAllowList.includes("*");
|
|
@@ -39,19 +61,22 @@ export function isMasterScope(ctx) {
|
|
|
39
61
|
* Core predicate. Returns true when the row is visible to the caller.
|
|
40
62
|
*
|
|
41
63
|
* - Master scope (isMasterScope === true) → true (wildcard)
|
|
42
|
-
* - Legacy bearer (oauthCtx === undefined) → true (back-compat)
|
|
43
64
|
* - row.createdBy ∈ oauthCtx.fromAllowList → true
|
|
44
65
|
* - row.namespace === prefix OR startsWith prefix+'/'
|
|
45
66
|
* for any prefix ∈ oauthCtx.namespaceReadPrefixes → true
|
|
46
67
|
* - otherwise → false
|
|
47
68
|
*
|
|
69
|
+
* `oauthCtx` is mandatory (see module-level contract note above): an omitted
|
|
70
|
+
* argument throws instead of silently granting wildcard access. Callers that
|
|
71
|
+
* need the old legacy-bearer wildcard behaviour must pass `LEGACY_WILDCARD_CTX`
|
|
72
|
+
* explicitly.
|
|
73
|
+
*
|
|
48
74
|
* Substring matches that don't fall on a '/' boundary are explicitly rejected
|
|
49
75
|
* (e.g. namespace="orchestrator/alphabet" does NOT match prefix
|
|
50
76
|
* "orchestrator/alpha"). This avoids the classic prefix-isolation bypass.
|
|
51
77
|
*/
|
|
52
78
|
export function passesScopeFilter(oauthCtx, row) {
|
|
53
|
-
|
|
54
|
-
return true;
|
|
79
|
+
assertOauthCtx(oauthCtx);
|
|
55
80
|
if (isMasterScope(oauthCtx))
|
|
56
81
|
return true;
|
|
57
82
|
const { createdBy, namespace } = row;
|
|
@@ -71,6 +96,7 @@ export function passesScopeFilter(oauthCtx, row) {
|
|
|
71
96
|
* Filter a list of rows (post-query). Used by list_* tools.
|
|
72
97
|
*/
|
|
73
98
|
export function scopeFilterList(oauthCtx, rows) {
|
|
99
|
+
assertOauthCtx(oauthCtx);
|
|
74
100
|
return rows.filter((r) => passesScopeFilter(oauthCtx, r));
|
|
75
101
|
}
|
|
76
102
|
/**
|
|
@@ -79,17 +105,16 @@ export function scopeFilterList(oauthCtx, rows) {
|
|
|
79
105
|
* to avoid leaking the difference between "absent" and "filtered out".
|
|
80
106
|
*/
|
|
81
107
|
export function scopeFilterGet(oauthCtx, row) {
|
|
108
|
+
assertOauthCtx(oauthCtx);
|
|
82
109
|
if (row == null)
|
|
83
110
|
return null;
|
|
84
111
|
return passesScopeFilter(oauthCtx, row) ? row : null;
|
|
85
112
|
}
|
|
86
113
|
/**
|
|
87
114
|
* Helper for callers that want a single "is master / legacy" predicate.
|
|
88
|
-
* Mirrors the legacy-bearer-passes-through convention.
|
|
89
115
|
*/
|
|
90
116
|
export function isWildcardScope(ctx) {
|
|
91
|
-
|
|
92
|
-
return true;
|
|
117
|
+
assertOauthCtx(ctx);
|
|
93
118
|
return isMasterScope(ctx);
|
|
94
119
|
}
|
|
95
120
|
//# sourceMappingURL=scope-filter.js.map
|
package/dist/scope-filter.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope-filter.js","sourceRoot":"","sources":["../src/scope-filter.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"scope-filter.js","sourceRoot":"","sources":["../src/scope-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAIH;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAa;IAC3C,KAAK,EAAE,QAAQ;IACf,aAAa,EAAE,CAAC,GAAG,CAAC;IACpB,qBAAqB,EAAE,EAAE;IACzB,sBAAsB,EAAE,EAAE;CAC3B,CAAC;AAEF,SAAS,cAAc,CAAC,QAAkB;IACxC,IAAI,QAAQ,IAAI,IAAI,EAAE,CAAC;QACrB,MAAM,IAAI,SAAS,CACjB,2EAA2E;YACzE,0EAA0E,CAC7E,CAAC;IACJ,CAAC;AACH,CAAC;AAWD;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,GAAa;IACzC,cAAc,CAAC,GAAG,CAAC,CAAC;IACpB,IAAI,GAAG,CAAC,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACxC,OAAO,GAAG,CAAC,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,iBAAiB,CAC/B,QAAkB,EAClB,GAAoB;IAEpB,cAAc,CAAC,QAAQ,CAAC,CAAC;IACzB,IAAI,aAAa,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IACzC,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,GAAG,CAAC;IACrC,IAAI,SAAS,IAAI,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAC;IACzE,IAAI,SAAS,EAAE,CAAC;QACd,KAAK,MAAM,CAAC,IAAI,QAAQ,CAAC,qBAAqB,EAAE,CAAC;YAC/C,IAAI,SAAS,KAAK,CAAC;gBAAE,OAAO,IAAI,CAAC;YACjC,IAAI,SAAS,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC;gBAAE,OAAO,IAAI,CAAC;QACjD,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,eAAe,CAC7B,QAAkB,EAClB,IAAS;IAET,cAAc,CAAC,QAAQ,CAAC,CAAC;IACzB,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,CAAC;AAC5D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAkB,EAClB,GAAyB;IAEzB,cAAc,CAAC,QAAQ,CAAC,CAAC;IACzB,IAAI,GAAG,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC;IAC7B,OAAO,iBAAiB,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;AACvD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,eAAe,CAAC,GAAa;IAC3C,cAAc,CAAC,GAAG,CAAC,CAAC;IACpB,OAAO,aAAa,CAAC,GAAG,CAAC,CAAC;AAC5B,CAAC"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vantageos/cloud-identity 0.2.0 — Domain-tenancy layer.
|
|
3
|
+
*
|
|
4
|
+
* Additive-only: does not modify any 0.1.0 exports.
|
|
5
|
+
*
|
|
6
|
+
* Provides:
|
|
7
|
+
* - WorkspaceRole literal union + Zod schema
|
|
8
|
+
* - Workspace type + Zod schema
|
|
9
|
+
* - WorkspaceMember type + Zod schema
|
|
10
|
+
* - TenantContext type + Zod schema
|
|
11
|
+
* - ScopeViolationError class (code: "SCOPE_VIOLATION")
|
|
12
|
+
* - getEffectiveTenantId(ctx, args) — multi-tenant isolation guard
|
|
13
|
+
* - decodeUnverifiedBearer(token) — DECODE-ONLY helper, NOT authentication.
|
|
14
|
+
* The decoded payload is attacker-controlled. Production code MUST use a
|
|
15
|
+
* signed JWT or opaque-token lookup before treating the payload as a
|
|
16
|
+
* trust boundary.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
export declare const workspaceRoleSchema: z.ZodEnum<{
|
|
20
|
+
Admin: "Admin";
|
|
21
|
+
Editor: "Editor";
|
|
22
|
+
Viewer: "Viewer";
|
|
23
|
+
}>;
|
|
24
|
+
export type WorkspaceRole = z.infer<typeof workspaceRoleSchema>;
|
|
25
|
+
export declare const workspaceSchema: z.ZodObject<{
|
|
26
|
+
id: z.ZodString;
|
|
27
|
+
name: z.ZodString;
|
|
28
|
+
createdAt: z.ZodNumber;
|
|
29
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
30
|
+
}, z.core.$strip>;
|
|
31
|
+
export type Workspace = z.infer<typeof workspaceSchema>;
|
|
32
|
+
export declare const workspaceMemberSchema: z.ZodObject<{
|
|
33
|
+
userId: z.ZodString;
|
|
34
|
+
workspaceId: z.ZodString;
|
|
35
|
+
role: z.ZodEnum<{
|
|
36
|
+
Admin: "Admin";
|
|
37
|
+
Editor: "Editor";
|
|
38
|
+
Viewer: "Viewer";
|
|
39
|
+
}>;
|
|
40
|
+
joinedAt: z.ZodNumber;
|
|
41
|
+
}, z.core.$strip>;
|
|
42
|
+
export type WorkspaceMember = z.infer<typeof workspaceMemberSchema>;
|
|
43
|
+
export declare const tenantContextSchema: z.ZodObject<{
|
|
44
|
+
workspaceId: z.ZodString;
|
|
45
|
+
userId: z.ZodString;
|
|
46
|
+
roles: z.ZodArray<z.ZodEnum<{
|
|
47
|
+
Admin: "Admin";
|
|
48
|
+
Editor: "Editor";
|
|
49
|
+
Viewer: "Viewer";
|
|
50
|
+
}>>;
|
|
51
|
+
}, z.core.$strip>;
|
|
52
|
+
export type TenantContext = z.infer<typeof tenantContextSchema>;
|
|
53
|
+
export interface ScopeViolationPayload {
|
|
54
|
+
requestedTenantId: string;
|
|
55
|
+
contextTenantId: string;
|
|
56
|
+
reason: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Thrown by `getEffectiveTenantId` when `args.workspaceId` does not match the
|
|
60
|
+
* workspaceId encoded in the bearer-resolved `TenantContext`. This error is the
|
|
61
|
+
* canonical cross-tenant isolation signal across the VantagePeers Cloud fleet.
|
|
62
|
+
*
|
|
63
|
+
* Callers MUST translate this to a 403 or MCP permission-denied — never 404.
|
|
64
|
+
*/
|
|
65
|
+
export declare class ScopeViolationError extends Error {
|
|
66
|
+
readonly code: "SCOPE_VIOLATION";
|
|
67
|
+
readonly payload: ScopeViolationPayload;
|
|
68
|
+
constructor(payload: ScopeViolationPayload);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the effective tenant workspace ID from a `TenantContext` + request args.
|
|
72
|
+
*
|
|
73
|
+
* If `args.workspaceId` differs from the context's `workspaceId` (derived from
|
|
74
|
+
* the bearer token), a `ScopeViolationError` is thrown immediately.
|
|
75
|
+
*
|
|
76
|
+
* This is the primary multi-tenant isolation gate — call it at the top of every
|
|
77
|
+
* MCP tool handler that accepts a `workspaceId` argument.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* const tenantId = getEffectiveTenantId(ctx, args);
|
|
82
|
+
* // safe to use tenantId for DB queries — caller's scope is verified
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export declare function getEffectiveTenantId(ctx: TenantContext, args: {
|
|
86
|
+
workspaceId: string;
|
|
87
|
+
}): string;
|
|
88
|
+
/**
|
|
89
|
+
* @security ⚠️ DECODE-ONLY. NOT AUTHENTICATION. NOT A TRUST BOUNDARY.
|
|
90
|
+
*
|
|
91
|
+
* Decodes a `base64(JSON({ userId, workspaceId, roles[] }))` payload and
|
|
92
|
+
* validates its SHAPE with Zod. It performs NO signature verification, NO
|
|
93
|
+
* issuer check, NO expiry check, NO replay-attack protection. The decoded
|
|
94
|
+
* payload is attacker-controlled and MUST NOT be used as the source of
|
|
95
|
+
* truth for multi-tenant isolation.
|
|
96
|
+
*
|
|
97
|
+
* Forgery example: any attacker producing
|
|
98
|
+
* base64(JSON({ userId:"x", workspaceId:"victim-org", roles:["Admin"] }))
|
|
99
|
+
* passes this function. Treating the returned `workspaceId` as authoritative
|
|
100
|
+
* — for example feeding it into `getEffectiveTenantId` as the trusted
|
|
101
|
+
* `ctx.workspaceId` — silently grants cross-tenant access.
|
|
102
|
+
*
|
|
103
|
+
* Use only as:
|
|
104
|
+
* - test fixture / harness helper
|
|
105
|
+
* - decoder for a bearer that was ALREADY verified by an upstream signed-JWT
|
|
106
|
+
* middleware (the production trust boundary)
|
|
107
|
+
*
|
|
108
|
+
* For the production trust boundary, callers MUST substitute a signed JWT
|
|
109
|
+
* verifier (not yet exported by this package) or an opaque-token lookup
|
|
110
|
+
* against a Convex tenancy table.
|
|
111
|
+
*
|
|
112
|
+
* Renamed from `resolveBearer` (0.2.0 pre-release) to make the lack of
|
|
113
|
+
* verification impossible to miss at the call-site.
|
|
114
|
+
*/
|
|
115
|
+
declare const bearerPayloadSchema: z.ZodObject<{
|
|
116
|
+
userId: z.ZodString;
|
|
117
|
+
workspaceId: z.ZodString;
|
|
118
|
+
roles: z.ZodArray<z.ZodEnum<{
|
|
119
|
+
Admin: "Admin";
|
|
120
|
+
Editor: "Editor";
|
|
121
|
+
Viewer: "Viewer";
|
|
122
|
+
}>>;
|
|
123
|
+
}, z.core.$strip>;
|
|
124
|
+
export type BearerPayload = z.infer<typeof bearerPayloadSchema>;
|
|
125
|
+
export declare function decodeUnverifiedBearer(token: string): Promise<BearerPayload>;
|
|
126
|
+
export {};
|
|
127
|
+
//# sourceMappingURL=tenancy-domain.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenancy-domain.d.ts","sourceRoot":"","sources":["../src/tenancy-domain.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB,eAAO,MAAM,mBAAmB;;;;EAAwC,CAAC;AACzE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAMhE,eAAO,MAAM,eAAe;;;;;iBAK1B,CAAC;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAMxD,eAAO,MAAM,qBAAqB;;;;;;;;;iBAKhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAMpE,eAAO,MAAM,mBAAmB;;;;;;;;iBAI9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAMhE,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAG,iBAAiB,CAAU;IAC3C,QAAQ,CAAC,OAAO,EAAE,qBAAqB,CAAC;gBAE5B,OAAO,EAAE,qBAAqB;CAS3C;AAMD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,aAAa,EAClB,IAAI,EAAE;IAAE,WAAW,EAAE,MAAM,CAAA;CAAE,GAC5B,MAAM,CASR;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,QAAA,MAAM,mBAAmB;;;;;;;;iBAIvB,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,CAAC,CAmBxB"}
|