@checkstack/auth-backend 0.4.33 → 0.5.1
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 +103 -0
- package/drizzle/0005_ambitious_oracle.sql +49 -0
- package/drizzle/meta/0005_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +13 -11
- package/src/dcr-ratelimit.it.test.ts +133 -0
- package/src/index.ts +182 -43
- package/src/mcp-oauth-config.ts +53 -0
- package/src/migration-chain-contract.test.ts +108 -0
- package/src/oauth-branch.test.ts +79 -0
- package/src/oauth-branch.ts +99 -0
- package/src/rate-limit.test.ts +34 -0
- package/src/rate-limit.ts +73 -0
- package/src/router.ts +102 -0
- package/src/schema.ts +72 -0
- package/src/scope-narrowing.test.ts +157 -0
- package/src/scope-narrowing.ts +113 -0
- package/src/utils/user.ts +65 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth scope narrowing (introspect-time) for the AI platform OAuth AS.
|
|
3
|
+
*
|
|
4
|
+
* Scope strings ARE qualified access-rule IDs (the same vocabulary
|
|
5
|
+
* `autoAuthMiddleware` enforces), plus a thin two-bundle umbrella layer:
|
|
6
|
+
* - `checkstack:read` -> every registered `*.read` rule.
|
|
7
|
+
* - `checkstack:write` -> every registered `*.read` + `*.manage` rule.
|
|
8
|
+
*
|
|
9
|
+
* Bundles are expanded from the LIVE access-rule catalog (never a hand-kept
|
|
10
|
+
* list) BEFORE intersection, so a bundle can still only ever narrow.
|
|
11
|
+
*
|
|
12
|
+
* The single invariant (LOCKED, decision 4): the narrowed set is always a
|
|
13
|
+
* subset of the principal's real rules. A token can only NARROW a principal,
|
|
14
|
+
* never widen it. This module is pure so the invariant is property/fuzz-tested
|
|
15
|
+
* (`scope-narrowing.test.ts`) without any DB.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The two curated umbrella scopes (decision §12). */
|
|
19
|
+
export const SCOPE_BUNDLE = {
|
|
20
|
+
read: "checkstack:read",
|
|
21
|
+
write: "checkstack:write",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type ScopeBundle = (typeof SCOPE_BUNDLE)[keyof typeof SCOPE_BUNDLE];
|
|
25
|
+
|
|
26
|
+
/** The admin wildcard rule (mirrors `enrichUser`'s admin -> `["*"]`). */
|
|
27
|
+
const ADMIN_WILDCARD = "*";
|
|
28
|
+
|
|
29
|
+
function isReadRule(ruleId: string): boolean {
|
|
30
|
+
return ruleId.endsWith(".read");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isManageRule(ruleId: string): boolean {
|
|
34
|
+
return ruleId.endsWith(".manage");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Expand the bundle umbrella scopes against the live catalog. Raw access-rule
|
|
39
|
+
* IDs pass through unchanged; unknown bundle strings are dropped (a client
|
|
40
|
+
* cannot invent a scope that maps to nothing useful).
|
|
41
|
+
*/
|
|
42
|
+
export function expandBundles({
|
|
43
|
+
requested,
|
|
44
|
+
catalog,
|
|
45
|
+
}: {
|
|
46
|
+
/** The token's granted scope strings (raw IDs and/or bundle IDs). */
|
|
47
|
+
requested: readonly string[];
|
|
48
|
+
/** All currently-registered qualified access-rule IDs. */
|
|
49
|
+
catalog: readonly string[];
|
|
50
|
+
}): string[] {
|
|
51
|
+
const expanded = new Set<string>();
|
|
52
|
+
for (const scope of requested) {
|
|
53
|
+
if (scope === SCOPE_BUNDLE.read) {
|
|
54
|
+
for (const id of catalog) if (isReadRule(id)) expanded.add(id);
|
|
55
|
+
} else if (scope === SCOPE_BUNDLE.write) {
|
|
56
|
+
for (const id of catalog) {
|
|
57
|
+
if (isReadRule(id) || isManageRule(id)) expanded.add(id);
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// Raw access-rule ID — keep verbatim (validated by the intersection).
|
|
61
|
+
expanded.add(scope);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...expanded];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The principal's EFFECTIVE rule set, used as the intersection ceiling. An
|
|
69
|
+
* admin (`accessRules` contains `"*"`) is treated as holding the entire live
|
|
70
|
+
* catalog, so an admin's token CAN carry any granted rule — but still only the
|
|
71
|
+
* rules the token was granted, never the bare `"*"` god-scope.
|
|
72
|
+
*/
|
|
73
|
+
function effectiveRules({
|
|
74
|
+
principalRules,
|
|
75
|
+
catalog,
|
|
76
|
+
}: {
|
|
77
|
+
principalRules: readonly string[];
|
|
78
|
+
catalog: readonly string[];
|
|
79
|
+
}): Set<string> {
|
|
80
|
+
if (principalRules.includes(ADMIN_WILDCARD)) {
|
|
81
|
+
return new Set(catalog);
|
|
82
|
+
}
|
|
83
|
+
return new Set(principalRules);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Narrow a token's granted scopes against the principal's LIVE rules.
|
|
88
|
+
*
|
|
89
|
+
* `narrowed = expandBundles(requested) ∩ effectiveRules(principalRules)`.
|
|
90
|
+
*
|
|
91
|
+
* Guarantees (proven by the property test):
|
|
92
|
+
* - `narrowed ⊆ effectiveRules(principalRules)` — never widens.
|
|
93
|
+
* - the result never contains the bare `"*"` wildcard (admin tokens carry the
|
|
94
|
+
* concrete expanded set, so a leaked admin token is not a god-token).
|
|
95
|
+
*/
|
|
96
|
+
export function narrowScopes({
|
|
97
|
+
requested,
|
|
98
|
+
principalRules,
|
|
99
|
+
catalog,
|
|
100
|
+
}: {
|
|
101
|
+
requested: readonly string[];
|
|
102
|
+
principalRules: readonly string[];
|
|
103
|
+
catalog: readonly string[];
|
|
104
|
+
}): string[] {
|
|
105
|
+
const expanded = expandBundles({ requested, catalog });
|
|
106
|
+
const ceiling = effectiveRules({ principalRules, catalog });
|
|
107
|
+
const narrowed = new Set<string>();
|
|
108
|
+
for (const rule of expanded) {
|
|
109
|
+
if (rule === ADMIN_WILDCARD) continue; // never propagate the god-scope
|
|
110
|
+
if (ceiling.has(rule)) narrowed.add(rule);
|
|
111
|
+
}
|
|
112
|
+
return [...narrowed];
|
|
113
|
+
}
|
package/src/utils/user.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { User } from "better-auth/types";
|
|
2
2
|
import { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
3
|
+
import { eq, inArray } from "drizzle-orm";
|
|
4
4
|
import type { RealUser } from "@checkstack/backend-api";
|
|
5
5
|
import * as schema from "../schema";
|
|
6
6
|
|
|
@@ -68,3 +68,67 @@ export const enrichUser = async (
|
|
|
68
68
|
teamIds,
|
|
69
69
|
};
|
|
70
70
|
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The fields of an `ApplicationUser` resolved from the database.
|
|
74
|
+
*/
|
|
75
|
+
export interface ApplicationPrincipalEnrichment {
|
|
76
|
+
id: string;
|
|
77
|
+
name: string;
|
|
78
|
+
roles: string[];
|
|
79
|
+
accessRules: string[];
|
|
80
|
+
teamIds: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve an application's CURRENT roles, access rules, and team memberships
|
|
85
|
+
* into the fields of an `ApplicationUser`.
|
|
86
|
+
*
|
|
87
|
+
* Shared by the API-key (`ck_`) authentication branch and the app-principal
|
|
88
|
+
* token verify path (automation `runAs` service accounts), so both resolve
|
|
89
|
+
* identically and LIVE - the principal is never frozen into a token. Mirrors
|
|
90
|
+
* {@link enrichUser}'s admin -> `*` expansion. Returns `undefined` when the
|
|
91
|
+
* application does not exist.
|
|
92
|
+
*/
|
|
93
|
+
export const enrichApplicationPrincipal = async (
|
|
94
|
+
applicationId: string,
|
|
95
|
+
db: SafeDatabase<typeof schema>,
|
|
96
|
+
): Promise<ApplicationPrincipalEnrichment | undefined> => {
|
|
97
|
+
const apps = await db
|
|
98
|
+
.select()
|
|
99
|
+
.from(schema.application)
|
|
100
|
+
.where(eq(schema.application.id, applicationId))
|
|
101
|
+
.limit(1);
|
|
102
|
+
const app = apps[0];
|
|
103
|
+
if (!app) return undefined;
|
|
104
|
+
|
|
105
|
+
const appRoles = await db
|
|
106
|
+
.select({ roleId: schema.applicationRole.roleId })
|
|
107
|
+
.from(schema.applicationRole)
|
|
108
|
+
.where(eq(schema.applicationRole.applicationId, applicationId));
|
|
109
|
+
const roleIds = appRoles.map((r) => r.roleId);
|
|
110
|
+
|
|
111
|
+
const accessRulesSet = new Set<string>();
|
|
112
|
+
if (roleIds.includes("admin")) accessRulesSet.add("*");
|
|
113
|
+
const nonAdminRoleIds = roleIds.filter((r) => r !== "admin");
|
|
114
|
+
if (nonAdminRoleIds.length > 0) {
|
|
115
|
+
const rolePerms = await db
|
|
116
|
+
.select({ accessRuleId: schema.roleAccessRule.accessRuleId })
|
|
117
|
+
.from(schema.roleAccessRule)
|
|
118
|
+
.where(inArray(schema.roleAccessRule.roleId, nonAdminRoleIds));
|
|
119
|
+
for (const rp of rolePerms) accessRulesSet.add(rp.accessRuleId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const appTeams = await db
|
|
123
|
+
.select({ teamId: schema.applicationTeam.teamId })
|
|
124
|
+
.from(schema.applicationTeam)
|
|
125
|
+
.where(eq(schema.applicationTeam.applicationId, applicationId));
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
id: app.id,
|
|
129
|
+
name: app.name,
|
|
130
|
+
roles: roleIds,
|
|
131
|
+
accessRules: [...accessRulesSet],
|
|
132
|
+
teamIds: appTeams.map((t) => t.teamId),
|
|
133
|
+
};
|
|
134
|
+
};
|