33 * these in canonical order (highest authority first) so the dashboard
44 * can render columns / build a level ladder without knowing role names.
55 *
6- * Roles the plugin doesn't expose at all (e.g. seeded but with the
7- * `is_hidden` flag set in the cloud plugin) are not returned by
8- * `systemRoles()` — there's no "advertised but absent" state.
6+ * Roles the plugin chooses not to expose at all (e.g. seeded but hidden)
7+ * are not returned by `systemRoles()` — there's no "advertised but
8+ * absent" state.
99 *
1010 * `available` indicates whether the role is assignable on the *org's
1111 * plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds
@@ -28,9 +28,9 @@ export type Permission = {
2828 // first appear in `allPermissions()`, so the plugin owns both the
2929 // bucket label and the section ordering. Omit for "no grouping".
3030 group ?: string ;
31- // Inverted rules (CASL `cannot`) surface as ✗ in the Roles page.
31+ // Inverted (deny) rules surface as ✗ in the Roles page.
3232 inverted ?: boolean ;
33- // CASL conditions (e.g. `{ envType: "PRODUCTION" }`) — when present,
33+ // Rule conditions (e.g. `{ envType: "PRODUCTION" }`) — when present,
3434 // the Roles page renders a tier badge alongside the permission row.
3535 conditions ?: Record < string , unknown > ;
3636} ;
@@ -54,7 +54,7 @@ export type RbacResource = {
5454 // Extra fields a route may pass for condition-based ability checks —
5555 // e.g. `envType` for env-tier-scoped rules ("Member can read envvars
5656 // unless envType === 'PRODUCTION'"). The plugin's ability matcher
57- // (CASL) reads these off the resource object; routes that don't use
57+ // reads these off the resource object; routes that don't use
5858 // conditional rules can keep passing `{ type, id? }`.
5959 [ key : string ] : unknown ;
6060} ;
@@ -89,6 +89,54 @@ export interface RbacAbility {
8989 canSuper ( ) : boolean ;
9090}
9191
92+ /**
93+ * Builds an ability from JWT scope strings like "read:runs",
94+ * "read:runs:run_abc", "read:all", "admin".
95+ *
96+ * This is the single source of truth for interpreting public-token scope
97+ * strings. Both the host's built-in fallback and any auth plugin import it
98+ * from here so a token minted by the host is decoded identically no matter
99+ * which auth path serves the request — two copies of this grammar would
100+ * drift, and the difference would silently change what a token grants.
101+ */
102+ export function buildJwtAbility ( scopes : string [ ] ) : RbacAbility {
103+ const matches = ( action : string , r : RbacResource ) : boolean =>
104+ scopes . some ( ( scope ) => {
105+ // Only the first two colons are delimiters — everything after the
106+ // second colon is the resource id (which may itself contain colons,
107+ // e.g. user-provided tags like "env:staging"). Naive
108+ // `split(":")` + 3-tuple destructuring truncated such ids to the
109+ // first segment and silently failed to match.
110+ const parts = scope . split ( ":" ) ;
111+ const scopeAction = parts [ 0 ] ;
112+ const scopeType = parts [ 1 ] ;
113+ const scopeId = parts . length > 2 ? parts . slice ( 2 ) . join ( ":" ) : undefined ;
114+ // Bare `admin` is the universal wildcard. `admin:<type>` is *not* —
115+ // it falls through to normal matching as action="admin" against
116+ // resources of that type. Treating `admin:<anything>` as universal
117+ // would silently broaden any such tokens beyond the narrow,
118+ // route-listed grant they had before scope-based abilities.
119+ if ( scopeAction === "admin" && ! scopeType ) return true ;
120+ if ( scopeAction !== action && scopeAction !== "*" ) return false ;
121+ if ( scopeType === "all" ) return true ;
122+ if ( scopeType !== r . type ) return false ;
123+ if ( ! scopeId ) return true ;
124+ return scopeId === r . id ;
125+ } ) ;
126+ return {
127+ can ( action : string , resource : RbacResource | RbacResource [ ] ) : boolean {
128+ // Array form means "any element passes → authorized", matching the
129+ // legacy multi-key authorization semantic.
130+ return Array . isArray ( resource )
131+ ? resource . some ( ( r ) => matches ( action , r ) )
132+ : matches ( action , resource ) ;
133+ } ,
134+ canSuper ( ) : boolean {
135+ return false ;
136+ } ,
137+ } ;
138+ }
139+
92140export type BearerAuthResult =
93141 | { ok : false ; status : 401 | 403 ; error : string }
94142 | {
@@ -127,8 +175,8 @@ export type PatAuthResult =
127175 } ;
128176
129177export interface RoleBaseAccessController {
130- // True when a real RBAC plugin is loaded (i.e. cloud) ; false when the
131- // OSS fallback is in use. Hosts gate behaviour that's only meaningful
178+ // True when a real RBAC plugin is loaded; false when the built-in
179+ // fallback is in use. Hosts gate behaviour that's only meaningful
132180 // when the plugin is present (e.g. skipping role-attachment writes,
133181 // hiding role-pickers in the UI, branching on whether ability checks
134182 // are authoritative or permissive).
0 commit comments