diff --git a/DESIGN.md b/DESIGN.md index 22e937a627..9e744c8aa8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -323,6 +323,56 @@ in write batch", poisoning the whole database env until restart. The regression of this is `unitTests/resources/dropTableGhost.test.js` (it fails by design on pre-fix bindings). +## Scoped tokens and synthetic-role identity (`security/tokenAuthentication.ts`, `security/impersonation.ts`) + +`create_authentication_tokens` with an inline `role` **object** mints a `sub: 'scoped-operation'` +JWT that embeds its whole (downgraded, deep-validated) permission set; the bearer needs no +`hdb_user`/`hdb_role` row and the `username` is attribution only. Minting is super_user-gated +(or trusted internal dispatch via `isOperationAuthorizationBypassed()`); a string `role` keeps its +legacy meaning (component-defined token, rejected by `validateOperationToken`). Scoped tokens get +no refresh token, touch no user record, and are therefore **irrevocable until expiry** — expiry is +the only control, which is why `auth.ts` evicts cached Bearer identities at exact `authExpiresAt` +rather than waiting for the auth-cache TTL. + +The attribution `username` must NOT name an existing `hdb_user` (rejected at mint; the default is +`scoped:`): code paths that rehydrate a user by name would otherwise substitute the real +principal's permissions for the token's — or fail-closed on the non-existent name. The three known +by-name sites are handled, all by the same `_scopedToken` short-circuit: the MQTT last-will replay +(`DurableSubscriptionsSession.ts` persists the scoped role/marker/expiry on the will and skips +rehydration — and both the restart-replay and the live abnormal-disconnect paths refuse to publish +a scoped will past `authExpiresAt`), the live-subscription stale-auth recheck (`Resource.ts` +`registerLiveSubscriptionForContext` keeps the embedded role as the identity), and the MCP +`list_changed` session refresh (`components/mcp/listChanged.ts` `refreshSessionUser`). The scoped +principal also cannot self-mint standing tokens: the passwordless path of `createTokens` rejects an +`hdb_user._scopedToken` requester. **Any future by-name rehydration must check `_scopedToken`.** A +user _created after minting_ with a colliding name is therefore inert at every current site; the +residual is only some _new_ unguarded by-name site — another reason to prefer short expiries. + +Scope of the `operations` allowlist: it gates the **operations API** (including the `sql` path, +which never reaches `verifyPerms` and calls `verifyOperationsAllowlist` directly from +`chooseOperation`) — it does NOT gate the application/REST/GraphQL/MQTT surfaces, which authorize +on translated table CRUD permissions only. A scoped token intended to be read-only on app +endpoints must carry restrictive table permissions; `operations: ['read_only']` alone does not +constrain REST writes if table perms allow them. + +The invariant to preserve when touching any synthetic (inline/impersonated/scoped) role: +`permissionsTranslator.getRolePermissions` memoizes translated permissions **by role name** (keyed +further by `__updatedtime__` + schema). A synthetic role must therefore never carry a constant +name or a per-request timestamp — two different permission sets would alias one cache slot (a +same-millisecond `Date.now()` was enough), leaking one principal's translated permissions to +another. `syntheticRoleName()` derives the name from a hash of the post-downgrade permission +content with `__updatedtime__: 0`, so identical sets share a slot and distinct sets can't collide; +`applyImpersonation` re-keys all three impersonation modes the same way (Mode B/C previously wrote +downgraded copies under the _persisted_ role's name). Synthetic translations live in a separate +256-entry LRU (`syntheticRolePermsMap`), not the permanent `rolePermsMap` — so >256 concurrently +live distinct permission sets degrade to per-request translation (a deliberate cliff; raise the +constant if a legitimate workload hits it). The `_` name prefix is the discriminator; a persisted +role named with a leading underscore lands in the LRU too (correct, just evictable). Relatedly, +the role `operations` allowlist gate in `verifyPerms` must stay **ahead of** the ambient privilege +early-returns (super_user, structure_user, system-table allowances): persisted roles can't combine +`super_user` with other permission keys, but inline roles can combine `structure_user` with an +allowlist, and the gate ordering is what keeps unlisted schema ops unreachable. + ## TLS hot-reload: cert vs. private key follow two different propagation paths (`security/keys.ts`) A renewed **certificate** and a renewed **private key** reach a worker's live TLS secure context diff --git a/components/mcp/listChanged.ts b/components/mcp/listChanged.ts index 2e53cd4b3b..9a68bc690c 100644 --- a/components/mcp/listChanged.ts +++ b/components/mcp/listChanged.ts @@ -215,6 +215,10 @@ function snapshotSessions(profile: McpProfile): RegisteredSession[] { } async function refreshSessionUser(record: RegisteredSession): Promise { + // A scoped token's username is attribution only — re-resolving it against hdb_user could + // substitute a real principal (created later with that name) and advertise a surface the token + // never granted. Its embedded role is fixed for the token's life, so there is nothing to refresh. + if (record.user?._scopedToken) return; const fresh = await resolveUser(record.user?.username); if (fresh) record.user = fresh; } diff --git a/components/mcp/toolRegistry.ts b/components/mcp/toolRegistry.ts index e2c9fa434d..fc0131ba4f 100644 --- a/components/mcp/toolRegistry.ts +++ b/components/mcp/toolRegistry.ts @@ -60,6 +60,8 @@ export interface ToolDescriptor { /** Authenticated user object as Harper builds it (subset we touch). */ export interface AuthedUser { username?: string; + // Attribution-only principal that must not be re-resolved against hdb_user (see refreshSessionUser). + _scopedToken?: boolean; role?: { role?: string; permission?: { diff --git a/components/mcp/tools/schemas/operationDescriptions.ts b/components/mcp/tools/schemas/operationDescriptions.ts index 8eb26cec1a..b7c3ca34a2 100644 --- a/components/mcp/tools/schemas/operationDescriptions.ts +++ b/components/mcp/tools/schemas/operationDescriptions.ts @@ -195,9 +195,9 @@ export const OPERATION_DESCRIPTIONS: Record = { // drop_role: security/role.ts:126 — Delete a role; refuses if assigned. drop_role: 'Deletes a role. Refused if any active user is still assigned to the role — drop or reassign those users first.', - // create_authentication_tokens: security/tokenAuthentication.ts:108 — Mint operation + refresh token pair, or (purpose: 'login') a login-exchange token. + // create_authentication_tokens: security/tokenAuthentication.ts:108 — Mint operation + refresh token pair, (purpose: 'login') a login-exchange token, or (role object) a scoped token. create_authentication_tokens: - "Creates a JWT operation token and a refresh token after validating credentials. Stores the refresh token on the user record. With purpose: 'login', instead mints a single short-lived login-exchange token (not usable as a Bearer API credential) intended for the `login` operation.", + "Creates a JWT operation token and a refresh token after validating credentials. Stores the refresh token on the user record. With purpose: 'login', instead mints a single short-lived login-exchange token (not usable as a Bearer API credential) intended for the `login` operation. With `role` as an inline role object (e.g. { permission: { operations: ['read_only'] } }), instead mints a single scoped token whose bearer is limited to the embedded permissions — requires a super_user caller; `username` is attribution only and must NOT name an existing user (defaults to 'scoped:'); no refresh token is issued and the token cannot be revoked before expiry. The operations allowlist gates the operations API (including sql); application/REST endpoints are governed by the embedded table permissions, so make those restrictive too.", // refresh_operation_token: security/tokenAuthentication.ts:171 — Mint new operation token from refresh. refresh_operation_token: 'Issues a new operation token using a valid refresh token, without re-authenticating with username/password.', diff --git a/integrationTests/apiTests/token-auth.test.mjs b/integrationTests/apiTests/token-auth.test.mjs index 1ad4ad6cba..0839ee1aef 100644 --- a/integrationTests/apiTests/token-auth.test.mjs +++ b/integrationTests/apiTests/token-auth.test.mjs @@ -39,6 +39,7 @@ suite('Token authentication', (ctx) => { let admin; let operationToken; let refreshToken; + let scopedToken; let authorizeLocal; before(async () => { @@ -193,4 +194,187 @@ suite('Token authentication', (ctx) => { assert.notStrictEqual(response.body.operation_token, undefined, response.text); assert.notStrictEqual(response.body.refresh_token, undefined, response.text); }); + + test('scoped token: super_user mints an inline-role token for a non-existent username', async () => { + const response = await client + .req() + .send({ + operation: 'create_authentication_tokens', + username: 'reporting-service', + role: { + permission: { + operations: ['read_only'], + [SCHEMA]: { + tables: { + // insert deliberately granted: the operations allowlist must still deny the insert op + [TABLE]: { read: true, insert: true, update: false, delete: false, attribute_permissions: [] }, + }, + }, + }, + }, + }) + .expect(200); + assert.notStrictEqual(response.body.operation_token, undefined, response.text); + assert.strictEqual(response.body.refresh_token, undefined, response.text); + scopedToken = response.body.operation_token; + }); + + test('scoped token bearer can run a listed read operation', async () => { + await request(client.operationsURL) + .post('') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${scopedToken}`) + .send({ + operation: 'search_by_hash', + schema: SCHEMA, + table: TABLE, + primary_key: PRIMARY_KEY, + hash_values: [1], + get_attributes: ['*'], + }) + .expect((r) => assert.equal(r.body.length, 1, r.text)) + .expect(200); + }); + + test('scoped token bearer reports its attribution username via user_info', async () => { + const response = await request(client.operationsURL) + .post('') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${scopedToken}`) + .send({ operation: 'user_info' }) + .expect(200); + assert.equal(response.body.username, 'reporting-service', response.text); + }); + + test('scoped token bearer is denied an unlisted operation despite table-level permission', async () => { + await request(client.operationsURL) + .post('') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${scopedToken}`) + .send({ + operation: 'insert', + schema: SCHEMA, + table: TABLE, + records: [{ employeeid: 99, firstname: 'Denied' }], + }) + .expect((r) => assert.ok(r.text.includes('not permitted'), r.text)) + .expect(403); + }); + + test('scoped token bearer is denied super_user-only operations', async () => { + await request(client.operationsURL) + .post('') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${scopedToken}`) + .send({ operation: 'get_configuration' }) + .expect(403); + }); + + test('scoped token can invoke an explicitly-listed super_user-only operation (gate-2 delegation)', async () => { + // get_configuration is SU-only; listing it in operations is a deliberate admin grant, so the + // gate-2 bypass must allow it for this non-super_user scoped token. + const mint = await client + .req() + .send({ + operation: 'create_authentication_tokens', + username: 'config-reader', + role: { permission: { operations: ['get_configuration'] } }, + }) + .expect(200); + await request(client.operationsURL) + .post('') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${mint.body.operation_token}`) + .send({ operation: 'get_configuration' }) + .expect(200); + }); + + test('scoped token mint with an unknown operation name is rejected', async () => { + await client + .req() + .send({ + operation: 'create_authentication_tokens', + role: { permission: { operations: ['totally_fake_op'] } }, + }) + .expect((r) => assert.ok(r.text.includes('totally_fake_op'), r.text)) + .expect(400); + }); + + test('scoped token allowlist is enforced on the SQL path', async () => { + // operations includes sql (via read_only) → SELECT allowed + await request(client.operationsURL) + .post('') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${scopedToken}`) + .send({ operation: 'sql', sql: `SELECT * FROM ${SCHEMA}.${TABLE} WHERE ${PRIMARY_KEY} = 1` }) + .expect((r) => assert.equal(r.body.length, 1, r.text)) + .expect(200); + + // operations without sql → SQL denied even though table CRUD perms would allow the read + const noSqlMint = await client + .req() + .send({ + operation: 'create_authentication_tokens', + username: 'no-sql-service', + role: { + permission: { + operations: ['search_by_hash'], + [SCHEMA]: { + tables: { + [TABLE]: { read: true, insert: false, update: false, delete: false, attribute_permissions: [] }, + }, + }, + }, + }, + }) + .expect(200); + await request(client.operationsURL) + .post('') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${noSqlMint.body.operation_token}`) + .send({ operation: 'sql', sql: `SELECT * FROM ${SCHEMA}.${TABLE} WHERE ${PRIMARY_KEY} = 1` }) + .expect((r) => assert.ok(r.text.includes('not permitted'), r.text)) + .expect(403); + }); + + test('an expired scoped token stops working through the authorization cache', async () => { + const mintResponse = await client + .req() + .send({ + operation: 'create_authentication_tokens', + username: 'short-lived', + role: { + permission: { + operations: ['read_only'], + [SCHEMA]: { + tables: { + [TABLE]: { read: true, insert: false, update: false, delete: false, attribute_permissions: [] }, + }, + }, + }, + }, + expires_in: '2s', + }) + .expect(200); + const shortToken = mintResponse.body.operation_token; + const search = () => + request(client.operationsURL) + .post('') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${shortToken}`) + .send({ + operation: 'search_by_hash', + schema: SCHEMA, + table: TABLE, + primary_key: PRIMARY_KEY, + hash_values: [1], + get_attributes: ['*'], + }); + // first use validates and populates the authorization cache; second proves the cached path + await search().expect(200); + await search().expect(200); + await new Promise((resolve) => setTimeout(resolve, 2500)); + // expiry must be exact even for a cached identity, and must reject — not act as anonymous + await search().expect(401); + }); }); diff --git a/integrationTests/security/subscription-revocation.test.ts b/integrationTests/security/subscription-revocation.test.ts index e97a09bacf..92ad2e5bc0 100644 --- a/integrationTests/security/subscription-revocation.test.ts +++ b/integrationTests/security/subscription-revocation.test.ts @@ -507,4 +507,86 @@ suite('Live subscription re-authorization (#1414)', { skip: skipSuite }, (ctx: C `WS subscription kept delivering after bearer token expired (closed=${sub.closed})` ); }); + + test('a scoped-token subscription survives rechecks and terminates at token expiry', async () => { + // A scoped token's attribution username is not a real hdb_user, so the recheck must use the + // token's embedded role — not re-resolve by name. Read is granted via the operations allowlist + // plus the same table read the other tests use. + const tokenResp = await client.req().send({ + operation: 'create_authentication_tokens', + username: 'scoped_collider', + role: { + permission: { + operations: ['read_only'], + data: { + tables: { + Owned: { read: true, insert: false, update: false, delete: false, attribute_permissions: [] }, + }, + }, + }, + }, + // Long enough that the multi-step recheck phase below (add_role + add_user + sweep + + // delivery probe, up to ~8s) completes well before expiry, so the survives-recheck + // assertion never races the expiry sweep. + expires_in: 20, + }); + strictEqual(tokenResp.status, 200, `scoped token issue failed: ${tokenResp.status} ${tokenResp.text}`); + const token = tokenResp.body?.operation_token; + const mintedAt = Date.now(); + ok(token, 'expected a scoped operation_token'); + strictEqual(tokenResp.body?.refresh_token, undefined, 'scoped token must not carry a refresh token'); + + const stream = openSse(restURL, '/Owned/', { Authorization: `Bearer ${token}` }); + try { + await sleep(800); // let the subscription establish + const before = stream.count(); + await insert({ id: `r-${seq++}`, value: 'scoped-before' }); + ok( + await waitFor(() => stream.count() > before, 6000), + `expected delivery while scoped token valid, saw ${stream.count()}` + ); + + // Now create a REAL user colliding with the token's attribution name, holding a role with + // NO read on Owned. This both triggers a re-auth sweep and sets up the substitution trap: + // if the recheck re-resolved the scoped principal by name it would adopt this user's + // (no-read) permissions and terminate the subscription. Continued delivery proves the + // embedded scoped role — not the colliding hdb_user — governs the recheck. + await client + .req() + .send({ operation: 'add_role', role: 'scoped_collider_role', permission: { super_user: false } }) + .expect(200); + await client + .req() + .send({ + operation: 'add_user', + role: 'scoped_collider_role', + username: 'scoped_collider', + password: 'Collide-pw-1414!', + active: true, + }) + .expect(200); + await sleep(1500); + const afterRecheck = stream.count(); + await insert({ id: `r-${seq++}`, value: 'scoped-after-recheck' }); + ok( + await waitFor(() => stream.count() > afterRecheck, 5000), + 'scoped subscription was wrongly re-resolved to the colliding hdb_user (terminated or substituted)' + ); + + // It must still expire with the token. Anchor on the mint time so the recheck phase's + // variable duration can't leave us short: wait until well past the 20s lifetime + sweep. + await sleep(Math.max(0, mintedAt + 22000 - Date.now())); + const probe = stream.count(); + await insert({ id: `r-${seq++}`, value: 'scoped-post-expiry' }); + await sleep(1500); + await assertOracleAlive('scoped-token-oracle'); + strictEqual( + stream.count(), + probe, + `scoped subscription kept delivering after token expiry (${stream.count() - probe} extra)` + ); + } finally { + stream.close(); + } + }); }); diff --git a/resources/Resource.ts b/resources/Resource.ts index 4dc22edb06..6dda2db0ec 100644 --- a/resources/Resource.ts +++ b/resources/Resource.ts @@ -962,15 +962,23 @@ function registerLiveSubscriptionForContext(subscription: any, resource: any, ad // JWT exp of the bearer credential (set by the auth layer); undefined for password/mTLS/session. authExpiresAt: user.authExpiresAt, recheck: async () => { - // Re-fetch current user state — the user/role cache is rebuilt on mutations — so a dropped or - // role-stripped user no longer authorizes. - const { findAndValidateUser } = require('../security/user'); - const fresh: any = await findAndValidateUser(username, undefined, false); - if (!fresh?.role) return false; - // Advance the subscription's context to the fresh user so downstream checks — context.user - // and getCurrentUser() (which reads the resource's context) — evaluate against current state, - // not the stale user captured at subscribe time. - if (context) (context as any).user = fresh; + let fresh: any; + if (user._scopedToken) { + // A scoped token's identity IS its embedded role — never re-resolve its attribution + // username against hdb_user (it may not exist, or may name an unrelated principal + // created later). Expiry (authExpiresAt above) is its only revocation. + fresh = user; + } else { + // Re-fetch current user state — the user/role cache is rebuilt on mutations — so a dropped or + // role-stripped user no longer authorizes. + const { findAndValidateUser } = require('../security/user'); + fresh = await findAndValidateUser(username, undefined, false); + if (!fresh?.role) return false; + // Advance the subscription's context to the fresh user so downstream checks — context.user + // and getCurrentUser() (which reads the resource's context) — evaluate against current state, + // not the stale user captured at subscribe time. + if (context) (context as any).user = fresh; + } // Re-run the same operation-level allowRead that granted the subscription. const reTarget: any = cloneRequestTarget(admittedTarget); reTarget.checkPermission = fresh.role?.permission; diff --git a/security/auth.ts b/security/auth.ts index d12cf43351..7b1d13778d 100644 --- a/security/auth.ts +++ b/security/auth.ts @@ -190,7 +190,13 @@ export async function authentication(request, nextHandler) { if (request.user) { // already authenticated } else if (authorization) { - const cachedUser = authorizationCache.get(authorization); + let cachedUser = authorizationCache.get(authorization); + // A cached Bearer identity must not outlive its token: expiry is the only revocation + // mechanism for scoped tokens, so it has to be exact, not cache-TTL-fuzzy. + if (cachedUser?.authExpiresAt && cachedUser.authExpiresAt * 1000 <= Date.now()) { + authorizationCache.delete(authorization); + cachedUser = undefined; + } if (cachedUser?.role) { // Shallow-clone so verifyPerms's `role.permission = fullRolePerms` reassignment // doesn't mutate the cache entry (defense-in-depth; operations and other @@ -235,6 +241,9 @@ export async function authentication(request, nextHandler) { throw error; } } + // A non-'invalid token' rejection (e.g. expired) must propagate, not fall + // through to caching an undefined user (which would read as anonymous). + throw error; } break; } diff --git a/security/impersonation.ts b/security/impersonation.ts index 9405016209..5c776bbcac 100644 --- a/security/impersonation.ts +++ b/security/impersonation.ts @@ -1,13 +1,47 @@ -import type { User } from './user.ts'; +import { createHash } from 'node:crypto'; +import type { User, UserRole } from './user.ts'; import type { ImpersonatePayload } from '../server/operationsServer.ts'; import { getUsersWithRolesCache } from './user.ts'; import { validateOperations } from '../utility/operationPermissions.ts'; +import { addRoleValidation } from '../validation/role_validation.ts'; import { ClientError } from '../utility/errors/hdbError.ts'; import harperLogger from '../utility/logging/harper_logger.ts'; import { getRoleByName } from './role.ts'; import { attachScopeToUser } from './operationScope.ts'; import { attachWorkloadIdentityToUser } from './credentialProvenance.ts'; +/** + * Content-derived identity for synthetic (inline) roles. getRolePermissions memoizes translated + * permissions by role name, so synthetic roles must never share a constant name: two inline roles + * with different permissions would alias in that cache and leak permissions across principals. + * Hashing the (already downgraded) permission content isolates distinct permission sets while + * letting identical ones share a cache entry. The paired __updatedtime__ of 0 keeps the memo key + * stable across requests; the content hash in the name is what invalidates on permission change. + * + * Object keys are canonicalized before hashing so two structurally-identical permission sets that + * differ only in key order (e.g. from different client request shapes) map to the same name and + * share one cache slot — otherwise they would each consume a distinct slot (never an aliasing bug, + * but it worsens the bounded synthetic-role cache pressure). + */ +export function syntheticRoleName(prefix: string, permission: object): string { + return `${prefix}_${createHash('sha256').update(canonicalJSON(permission)).digest('hex').slice(0, 24)}`; +} + +// Array order is intentionally preserved: operations is order-insensitive semantically, but +// canonicalizing it too would be a broader behavior change than this cache-sharing fix needs. +function canonicalJSON(value: any): string { + return JSON.stringify(value, (_key, v) => + v && typeof v === 'object' && !Array.isArray(v) + ? Object.keys(v) + .sort() + .reduce((acc: any, k) => { + acc[k] = v[k]; + return acc; + }, {}) + : v + ); +} + /** * Applies impersonation to a request. The authenticated user must be a super_user. * Returns a new User object with downgraded permissions based on the impersonate payload. @@ -49,6 +83,17 @@ export async function applyImpersonation(authenticatedUser: User, payload: Imper // a lesser role — and then mint a 30-day credential that createTokens would no longer refuse. attachWorkloadIdentityToUser(impersonatedUser, (authenticatedUser as any).fromWorkloadIdentity); + // Re-key the synthetic role by its effective (post-downgrade) content so it can never alias + // another impersonation's permissions or poison a persisted role's memoized translation — + // getRolePermissions caches by role name (see syntheticRoleName). + if (impersonatedUser.role) { + impersonatedUser.role = { + ...impersonatedUser.role, + role: syntheticRoleName('_impersonated', impersonatedUser.role.permission), + __updatedtime__: 0, + }; + } + // Tag for audit trail impersonatedUser._impersonated = true; impersonatedUser._impersonatedBy = authenticatedUser.username; @@ -60,9 +105,60 @@ export async function applyImpersonation(authenticatedUser: User, payload: Imper return impersonatedUser; } -function validatePayload(payload: ImpersonatePayload): void { +/** + * Builds the synthetic user embedded in a scoped authentication token + * (create_authentication_tokens with an inline `role` object). Same gate and downgrade rules as + * impersonation Mode A. `trusted` marks internal dispatch (operation authorization bypassed), + * where no authenticated minter exists. + */ +export async function buildScopedTokenUser( + minter: User | undefined, + payload: ImpersonatePayload, + trusted = false +): Promise { + if (!trusted && !minter?.role?.permission?.super_user) { + throw new ClientError('Only super_user can create a token with an inline role', 403); + } + validatePayload(payload, 'scoped token role'); + if (!payload.role) { + throw new ClientError("A scoped token requires 'role' with 'permission'"); + } + const username = payload.username || (minter?.username && `scoped:${minter.username}`); + if (!username || typeof username !== 'string') { + throw new ClientError("A scoped token requires a 'username'"); + } + // The attribution name must never collide with a real principal: paths that rehydrate a user + // by name (e.g. MQTT last-will replay) would resolve the token's bearer to that user's full + // permissions. This also keeps scoped-token activity distinguishable in audit logs. + if ((await getUsersWithRolesCache())?.has(username)) { + throw new ClientError(`'username' must not name an existing user; scoped-token attribution is a label`); + } + // Downgrade first so validation and the content hash see the effective permission set. + const permission = { + ...payload.role.permission, + super_user: false, + cluster_user: false, + } as UserRole['permission']; + // Full persisted-role validation: a malformed shape must fail at mint (400), not at every use. + const deepValidation = addRoleValidation({ role: 'scoped_token', permission }); + if (deepValidation) throw deepValidation; + const roleName = syntheticRoleName('_scoped_token', permission); + return { + username, + active: true, + role: { + permission, + role: roleName, + id: roleName, + __updatedtime__: 0, + __createdtime__: 0, + }, + }; +} + +function validatePayload(payload: ImpersonatePayload, context = 'impersonate payload'): void { if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { - throw new ClientError('Invalid impersonate payload: must be an object'); + throw new ClientError(`Invalid ${context}: must be an object`); } const hasRole = payload.role !== undefined; @@ -70,33 +166,31 @@ function validatePayload(payload: ImpersonatePayload): void { const hasRoleName = typeof payload.role_name === 'string' && payload.role_name.length > 0; if (!hasRole && !hasUsername && !hasRoleName) { - throw new ClientError( - "Invalid impersonate payload: must include 'username', 'role_name', or 'role' with 'permission'" - ); + throw new ClientError(`Invalid ${context}: must include 'username', 'role_name', or 'role' with 'permission'`); } if (hasRole) { if (typeof payload.role !== 'object' || payload.role === null) { - throw new ClientError("Invalid impersonate payload: 'role' must be an object"); + throw new ClientError(`Invalid ${context}: 'role' must be an object`); } if (typeof payload.role.permission !== 'object' || payload.role.permission === null) { - throw new ClientError("Invalid impersonate payload: 'role.permission' must be an object"); + throw new ClientError(`Invalid ${context}: 'role.permission' must be an object`); } - validateOperationsField(payload.role.permission); + validateOperationsField(payload.role.permission, context); } } -function validateOperationsField(permission: Record): void { +function validateOperationsField(permission: Record, context = 'impersonate payload'): void { const operations = permission.operations; if (operations === undefined) return; if (!Array.isArray(operations)) { - throw new ClientError("Invalid impersonate payload: 'operations' must be an array"); + throw new ClientError(`Invalid ${context}: 'operations' must be an array`); } const invalidOp = validateOperations(operations); if (invalidOp !== null) { - throw new ClientError(`Invalid impersonate payload: unknown operation '${invalidOp}'`); + throw new ClientError(`Invalid ${context}: unknown operation '${invalidOp}'`); } } diff --git a/security/permissionsTranslator.js b/security/permissionsTranslator.js index a80bc4521c..b407dd50b2 100644 --- a/security/permissionsTranslator.js +++ b/security/permissionsTranslator.js @@ -13,6 +13,13 @@ module.exports = { const rolePermsMap = Object.create(null); const permsTemplateObj = (permsKey) => ({ key: permsKey, perms: {} }); +// Synthetic roles (impersonation/scoped tokens, names prefixed '_' by syntheticRoleName) have +// content-derived names, so their population is unbounded — they get their own LRU-bounded map +// instead of the permanent rolePermsMap, or minting many distinct permission sets would grow a +// per-worker memo that is never freed. +const MAX_SYNTHETIC_ROLE_ENTRIES = 256; +const syntheticRolePermsMap = new Map(); + const schemaPermsTemplate = (describePerm = false) => ({ describe: describePerm, tables: {}, @@ -81,6 +88,22 @@ function getRolePermissions(role) { // translation to get an updated permissions set const permsKey = JSON.stringify([role['__updatedtime__'], nonSysSchema]); + if (roleName.startsWith('_')) { + const cached = syntheticRolePermsMap.get(roleName); + if (cached && cached.key === permsKey) { + syntheticRolePermsMap.delete(roleName); + syntheticRolePermsMap.set(roleName, cached); + return cached.perms; + } + const newRolePerms = translateRolePermissions(role, nonSysSchema); + syntheticRolePermsMap.delete(roleName); + syntheticRolePermsMap.set(roleName, { key: permsKey, perms: newRolePerms }); + if (syntheticRolePermsMap.size > MAX_SYNTHETIC_ROLE_ENTRIES) { + syntheticRolePermsMap.delete(syntheticRolePermsMap.keys().next().value); + } + return newRolePerms; + } + //If key exists already, we can return the cached value if (rolePermsMap[roleName] && rolePermsMap[roleName].key === permsKey) { return rolePermsMap[roleName].perms; diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index 09e7be8583..56b7548618 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -22,6 +22,9 @@ import { isWorkloadIdentityPrincipal, markTokenAsWorkloadIdentity, } from './credentialProvenance.ts'; +import { buildScopedTokenUser, syntheticRoleName } from './impersonation.ts'; +import type { ImpersonatePayload } from '../server/operationsServer.ts'; +import { expandOperationsPerms } from '../utility/operationPermissions.ts'; import { update } from '../dataLayer/insert.ts'; import UpdateObject from '../dataLayer/UpdateObject.ts'; import * as signalling from '../utility/signalling.ts'; @@ -47,6 +50,10 @@ const TOKEN_TYPE = { // TOKEN_TYPE.OPERATION, so validateOperationToken's Bearer-API path rejects it automatically — // it can't be replayed as a general API credential the way a full operation token could. LOGIN: 'login', + // Minted by createTokens with an inline `role` object (super_user-gated): the token embeds its + // own downgraded permission set and its bearer needs no hdb_user row. Accepted by + // validateOperationToken, which builds a synthetic user from the embedded role. + SCOPED: 'scoped-operation', }; interface JWTRSAKeys { @@ -58,7 +65,10 @@ interface JWTRSAKeys { interface AuthObject { username?: string; password?: string; - role?: string; + // A string role is stamped into the payload verbatim for component-defined token validation + // (such tokens are rejected by validateOperationToken). An object role mints a scoped token — + // see TOKEN_TYPE.SCOPED. + role?: string | ImpersonatePayload['role']; expires_in?: string | number; hdb_user?: User; // 'login' mints a single short-lived, login-scoped token instead of an operation/refresh pair — @@ -133,7 +143,7 @@ export async function createTokens(authObj: AuthObject): Promise { Joi.object({ username: Joi.string().optional(), password: Joi.string().optional(), - role: Joi.string().optional(), + role: Joi.alternatives(Joi.string(), Joi.object()).optional(), expires_in: Joi.alternatives(Joi.string(), Joi.number()).optional(), purpose: Joi.string().valid('login').optional(), }) @@ -165,6 +175,10 @@ export async function createTokens(authObj: AuthObject): Promise { throw new ClientError('a scoped token cannot mint authentication tokens', HTTP_STATUS_CODES.FORBIDDEN); } + if (authObj?.role && typeof authObj.role === 'object') { + return createScopedToken(authObj); + } + let user: any; try { // Trusted bypass is dispatch/async-context state (set by a component calling @@ -173,6 +187,12 @@ export async function createTokens(authObj: AuthObject): Promise { // without a password (see operationAuthorizationState.ts). let validatePassword: boolean = !isOperationAuthorizationBypassed(); if (!authObj.username && !authObj.password) { + // A scoped-token bearer must not self-mint: its username is an unverified attribution + // label, and resolving it here without a password would hand out standing operation/ + // refresh tokens for whatever real user later takes that name (privilege escalation). + if (authObj.hdb_user?._scopedToken) { + throw new ClientError(AUTHENTICATION_ERROR_MSGS.INVALID_CREDENTIALS, HTTP_STATUS_CODES.UNAUTHORIZED); + } // if the username and password are not provided, use the hdb_user making the request. authObj.username = authObj.hdb_user?.username; // the password would have been checked by authHandler before getting here @@ -253,6 +273,57 @@ export async function createTokens(authObj: AuthObject): Promise { }; } +/** + * Mints a scoped token: a single operation-usable JWT that embeds an inline role object, so its + * bearer needs no hdb_user or hdb_role row. Requires an authenticated super_user minter (or + * trusted internal dispatch). `username` is attribution only, must NOT name an existing user, + * and defaults to `scoped:`. No refresh token is issued and no user record is touched, + * so the token is irrevocable until expiry — size expires_in accordingly. + */ +// Measured on the signed token (base64url payload + signature), so it reflects what the +// Authorization header actually carries; keeps tokens inside common 16KB header limits. +const MAX_SCOPED_TOKEN_LENGTH = 12288; + +async function createScopedToken(authObj: AuthObject): Promise { + if (authObj.password) { + throw new ClientError("'password' cannot be combined with an inline 'role' object"); + } + if (authObj.purpose) { + throw new ClientError("'purpose' cannot be combined with an inline 'role' object"); + } + const scopedUser = await buildScopedTokenUser( + authObj.hdb_user, + { username: authObj.username, role: authObj.role as ImpersonatePayload['role'] }, + isOperationAuthorizationBypassed() + ); + const keys: JWTRSAKeys = await getJWTRSAKeys(); + const operationToken = jwt.sign( + { + username: scopedUser.username, + super_user: false, + role: { permission: scopedUser.role.permission }, + minted_by: authObj.hdb_user?.username, + }, + { key: keys.privateKey, passphrase: keys.passphrase } satisfies Secret, + { + expiresIn: (authObj.expires_in ?? OPERATION_TOKEN_TIMEOUT) as StringValue, + algorithm: RSA_ALGORITHM, + subject: TOKEN_TYPE.SCOPED, + } satisfies SignOptions + ); + if (operationToken.length > MAX_SCOPED_TOKEN_LENGTH) { + throw new ClientError( + `the minted token exceeds ${MAX_SCOPED_TOKEN_LENGTH} bytes and would not fit in an Authorization header; reduce the role permission size` + ); + } + // role.role is the content hash of the granted permission set — logged so an operator can + // correlate outstanding tokens with what they grant. + logger.info( + `Scoped token minted by "${authObj.hdb_user?.username ?? ''}" for "${scopedUser.username}" (${scopedUser.role.role})` + ); + return { operation_token: operationToken }; +} + /** * Refreshes the operation token using the refresh token. * @param tokenObj @@ -343,10 +414,22 @@ export async function validateLoginToken(token: string): Promise { async function validateToken(token: string, tokenType: string): Promise { try { const keys: JWTRSAKeys = await getJWTRSAKeys(); - const tokenVerified = jwt.verify(token, keys.publicKey, { - algorithms: [RSA_ALGORITHM], - subject: tokenType, - }) as JwtPayload; + // The OPERATION type also accepts scoped tokens, so the subject is checked after + // verification rather than pinned in the verify options. + const tokenVerified = jwt.verify( + token, + keys.publicKey, + tokenType === TOKEN_TYPE.OPERATION + ? { algorithms: [RSA_ALGORITHM] } + : { algorithms: [RSA_ALGORITHM], subject: tokenType } + ) as JwtPayload; + + if (tokenType === TOKEN_TYPE.OPERATION && tokenVerified.sub === TOKEN_TYPE.SCOPED) { + return buildUserFromScopedToken(tokenVerified); + } + if (tokenVerified.sub !== tokenType) { + throw new Error('Invalid token'); + } // If a role is present, it means the token is not an operation token. The validation of // the token will happen in the respective function/component that uses the token. @@ -379,6 +462,38 @@ async function validateToken(token: string, tokenType: string): Promise { } } +/** + * Builds the request user for a verified scoped token from its embedded role. No hdb_user lookup: + * the signed claims are the whole identity. The downgrade is re-applied here as defense-in-depth, + * so no scoped token — whatever minted it — can ever assert super_user or cluster_user. + */ +function buildUserFromScopedToken(claims: JwtPayload): User { + const embedded = (claims.role as { permission?: Record })?.permission; + if (!embedded || typeof embedded !== 'object' || Array.isArray(embedded) || typeof claims.username !== 'string') { + throw new Error('Invalid token'); + } + const permission: Record = { ...embedded, super_user: false, cluster_user: false }; + // Hashed from the server-side downgraded clone (before the _expandedOperations Set is attached), + // so the memo key reflects the effective permissions — see syntheticRoleName. + const roleName = syntheticRoleName('_scoped_token', permission); + if (Array.isArray(permission.operations)) { + permission._expandedOperations = expandOperationsPerms(permission.operations as string[]); + } + return { + username: claims.username, + active: true, + _scopedToken: true, + _mintedBy: claims.minted_by, + role: { + permission: permission as User['role']['permission'], + role: roleName, + id: roleName, + __updatedtime__: 0, + __createdtime__: 0, + }, + }; +} + /** * Decodes a JWT and returns its payload. * @param {string} token The JWT token to decode. diff --git a/server/DurableSubscriptionsSession.ts b/server/DurableSubscriptionsSession.ts index 16142860f4..c1c119b045 100644 --- a/server/DurableSubscriptionsSession.ts +++ b/server/DurableSubscriptionsSession.ts @@ -32,6 +32,24 @@ function getDurableSession() { return _DurableSession; } let _LastWill: any; +/** + * A scoped token's only revocation is expiry, so its will must not publish past it. Keyed off the + * persisted will principal (not any live session user) so both will paths agree on one source. + * Fails closed: a scoped will with no recorded expiry is treated as expired rather than published. + */ +function isWillFromExpiredScopedToken(will: any): boolean { + const user = will?.user; + if (!user?._scopedToken) return false; + return !user.authExpiresAt || user.authExpiresAt * 1000 <= Date.now(); +} + +/** Drops the runtime-only pre-expanded operations Set so the permission set is storage-safe. */ +function stripRuntimePermissionState(permission: any): any { + if (!permission || typeof permission !== 'object') return permission; + const { _expandedOperations, ...durable } = permission; + return durable; +} + function getLastWill() { if (!_LastWill) { _LastWill = table({ @@ -56,13 +74,23 @@ if (getWorkerIndex() === 0) { for await (const will of getLastWill().search({})) { const data = will.data; const message = { ...will }; - if (message.user?.username) message.user = await (server as any).getUser(message.user.username); try { - await publishMessage(message, data, message); + if (message.user?._scopedToken) { + // A scoped token's username is attribution only; never rehydrate it by name (that could + // substitute a real principal). The will carries the token's own downgraded role. + if (!isWillFromExpiredScopedToken(message)) { + await publishMessage(message, data, message); + } else warn('Dropping will from an expired scoped token', data); + } else { + if (message.user?.username) message.user = await (server as any).getUser(message.user.username); + await publishMessage(message, data, message); + } + await getLastWill().delete(will.id); } catch { + // One will's publish/delete failure must not abort replay of the rest; the row stays and + // is retried on the next restart. warn('Failed to publish will', data); } - getLastWill().delete(will.id); } })(); } @@ -125,7 +153,22 @@ export async function getSession({ } if (will) { will.id = sessionId; - will.user = { username: user?.username }; + // A scoped-token bearer's will must carry the token's own role and expiry: its username is + // attribution only and cannot be rehydrated from hdb_user at replay time. Persist only the + // durable permission fields — not the runtime-only _expandedOperations Set, which is rebuilt + // on read and would not round-trip through storage. + will.user = user?._scopedToken + ? { + username: user.username, + _scopedToken: true, + authExpiresAt: user.authExpiresAt, + role: user.role && { + role: user.role.role, + id: user.role.id, + permission: stripRuntimePermissionState(user.role.permission), + }, + } + : { username: user?.username }; // Must be durably persisted before CONNACK is sent (getSession() resolving is what lets // mqtt.ts send CONNACK). Otherwise a client that connects and then disconnects abruptly // (no DISCONNECT packet) can race ahead of this write: SubscriptionsSession.disconnect() @@ -382,8 +425,11 @@ class SubscriptionsSession { try { if (!clientTerminated) { const will = await getLastWill().get(this.sessionId); - if (will) { - await publishMessage(will, will.data, context); + if (will && !isWillFromExpiredScopedToken(will)) { + // A scoped will authorizes under its own embedded role, never the disconnecting + // session's user (which may be a later same-clientId reconnect). + const willContext = will.user?._scopedToken ? { ...context, user: will.user } : context; + await publishMessage(will, will.data, willContext); } } } finally { diff --git a/server/serverHelpers/serverHandlers.js b/server/serverHelpers/serverHandlers.js index 4d621f1141..6da3d60d11 100644 --- a/server/serverHelpers/serverHandlers.js +++ b/server/serverHelpers/serverHandlers.js @@ -114,10 +114,10 @@ function authHandler(req, resp, done) { const isAuthOperation = !NO_AUTH_OPERATIONS.includes(req.body.operation); if ( isAuthOperation || - // If create token is called without username/password in the body it needs to be authorized + // Create token needs to be authorized when called without username/password in the body, or + // with an inline role object (scoped-token minting is gated on the authenticated requester) (req.body.operation === terms.OPERATIONS_ENUM.CREATE_AUTHENTICATION_TOKENS && - !req.body.username && - !req.body.password) + ((!req.body.username && !req.body.password) || (req.body.role && typeof req.body.role === 'object'))) ) { pAuthorize(req, resp) .then(async (userData) => { diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index c98d82b740..8b4c95d9cf 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -256,6 +256,22 @@ export function chooseOperation(json: OperationRequestBody, bypassAuth = false) // path. This changes no outcome today — the branch that would act on the denial is dead // (#2202) — but #2202 needs an unforgeable carrier before making it live. if (!bypassAuth) { + // The SQL path never reaches verifyPerms, so the role `operations` allowlist must be + // enforced here — otherwise an allowlisted role reaches unlisted operations via `sql`. + // json.operation is already the API name ('sql', or the outer job op like 'export_local'). + const allowlistDenial = opAuth.verifyOperationsAllowlist(json, json.operation); + if (allowlistDenial) { + operationLog.error(`${HTTP_STATUS_CODES.FORBIDDEN} from operation ${json.operation}`); + operationLog.warn(`User '${json.hdb_user?.username}' is not permitted to ${json.operation}`); + throw handleHDBError( + new Error(), + allowlistDenial, + hdbErrors.HTTP_STATUS_CODES.FORBIDDEN, + undefined, + undefined, + true + ); + } // `json.operation` explicitly — the operation this dispatch already resolved, not a // field read back off the request body. const astPermCheck = sql.checkASTPermissions(json, parsedSqlObject, json.operation); diff --git a/unitTests/components/mcp/listChanged.test.js b/unitTests/components/mcp/listChanged.test.js index b157f66356..e65be68ce1 100644 --- a/unitTests/components/mcp/listChanged.test.js +++ b/unitTests/components/mcp/listChanged.test.js @@ -190,6 +190,34 @@ describe('mcp/listChanged', () => { assert.equal(rec.user, aliceWithFoo, 'record.user updated to the freshly-resolved user'); }); + it('a scoped-token session is never re-resolved by name (no privilege substitution)', async () => { + // A scoped token's attribution username may later collide with a real hdb_user; re-resolving + // it here would advertise that user's surface. The session's embedded role must stay put. + const scopedUser = { + username: 'reporting-svc', + _scopedToken: true, + role: { permission: { operations: ['read_only'] } }, + }; + const collidingRealUser = { + username: 'reporting-svc', + role: { permission: { super_user: true } }, + }; + let resolverCalled = false; + _setUserResolverForTest(async () => { + resolverCalled = true; + return collidingRealUser; + }); + + initListChanged(); + const rec = registerSession('scoped-sid', 'application', scopedUser); + seedSessionSnapshot('scoped-sid'); + + fakeItc._fireUser(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(resolverCalled, false, 'scoped session must not trigger a by-name user resolve'); + assert.equal(rec.user, scopedUser, 'record.user must remain the embedded scoped principal'); + }); + it('schema events also fan out (application profile)', async () => { initListChanged(); addTool({ diff --git a/unitTests/security/impersonation.test.js b/unitTests/security/impersonation.test.js index 444d7797a4..637830f058 100644 --- a/unitTests/security/impersonation.test.js +++ b/unitTests/security/impersonation.test.js @@ -283,7 +283,7 @@ describe('security/impersonation.ts', () => { const result = await applyImpersonation(su, payload); assert.strictEqual(result.username, 'HDB_ADMIN'); assert.strictEqual(result.role.permission.super_user, false); - assert.strictEqual(result.role.role, '_impersonated'); + assert.match(result.role.role, /^_impersonated_[0-9a-f]{24}$/); assert.deepStrictEqual(result.role.permission.dev, payload.role.permission.dev); }); @@ -342,7 +342,7 @@ describe('security/impersonation.ts', () => { const result = await applyImpersonation(su, payload); // Should use inline permissions, not look up 'custom_context' from cache - assert.strictEqual(result.role.role, '_impersonated'); + assert.match(result.role.role, /^_impersonated_[0-9a-f]{24}$/); assert.strictEqual(result.username, 'custom_context'); assert.ok(result.role.permission.dev); }); @@ -553,7 +553,7 @@ describe('security/impersonation.ts', () => { }; const result = await applyImpersonation(su, payload); - assert.strictEqual(result.role.role, '_impersonated'); + assert.match(result.role.role, /^_impersonated_[0-9a-f]{24}$/); assert.ok(result.role.permission.dev); }); }); @@ -637,4 +637,83 @@ describe('security/impersonation.ts', () => { assert.strictEqual(impersonated.tokenOperations, undefined); }); }); + + describe('buildScopedTokenUser', () => { + const { buildScopedTokenUser } = require('#src/security/impersonation'); + + before(async () => { + await userModule.setUsersWithRolesCache(new Map([['real_user', { username: 'real_user', active: true }]])); + }); + + it('trusted internal dispatch can mint without an authenticated minter', async () => { + const user = await buildScopedTokenUser( + undefined, + { username: 'internal-svc', role: { permission: { operations: ['read_only'] } } }, + true + ); + assert.strictEqual(user.username, 'internal-svc'); + assert.match(user.role.role, /^_scoped_token_[0-9a-f]{24}$/); + assert.strictEqual(user.role.permission.super_user, false); + }); + + it('untrusted mint without a super_user minter is rejected with 403', async () => { + await assert.rejects( + () => buildScopedTokenUser(undefined, { role: { permission: { operations: ['read_only'] } } }, false), + (err) => err.statusCode === 403 + ); + }); + + it('trusted mint without any username is rejected', async () => { + await assert.rejects( + () => buildScopedTokenUser(undefined, { role: { permission: { operations: ['read_only'] } } }, true), + (err) => /username/.test(err.message) + ); + }); + + it('an attribution username naming an existing user is rejected', async () => { + await assert.rejects( + () => + buildScopedTokenUser( + undefined, + { username: 'real_user', role: { permission: { operations: ['read_only'] } } }, + true + ), + (err) => /must not name an existing user/.test(err.message) + ); + }); + + it('default attribution is the scoped-prefixed minter name', async () => { + const minter = makeSuperUser('the_admin'); + const user = await buildScopedTokenUser(minter, { role: { permission: { operations: ['read_only'] } } }); + assert.strictEqual(user.username, 'scoped:the_admin'); + }); + + it('identical permission content produces the same synthetic role identity', async () => { + const a = await buildScopedTokenUser( + undefined, + { username: 'a', role: { permission: { operations: ['read_only'] } } }, + true + ); + const b = await buildScopedTokenUser( + undefined, + { username: 'b', role: { permission: { operations: ['read_only'] } } }, + true + ); + const c = await buildScopedTokenUser( + undefined, + { username: 'c', role: { permission: { operations: ['insert'] } } }, + true + ); + assert.strictEqual(a.role.role, b.role.role); + assert.notStrictEqual(a.role.role, c.role.role); + }); + + it('permission key order does not change the synthetic role identity', async () => { + const p1 = { operations: ['read_only'], structure_user: false }; + const p2 = { structure_user: false, operations: ['read_only'] }; + const a = await buildScopedTokenUser(undefined, { username: 'a', role: { permission: p1 } }, true); + const b = await buildScopedTokenUser(undefined, { username: 'b', role: { permission: p2 } }, true); + assert.strictEqual(a.role.role, b.role.role, 'reordered keys must map to the same cache identity'); + }); + }); }); diff --git a/unitTests/security/tokenAuthentication.test.js b/unitTests/security/tokenAuthentication.test.js index 9cafb18a9a..75d45cefcc 100644 --- a/unitTests/security/tokenAuthentication.test.js +++ b/unitTests/security/tokenAuthentication.test.js @@ -647,6 +647,266 @@ describe('test validateOperationToken function', () => { }); }); +describe('test scoped tokens (inline role object)', () => { + const token_auth_plain = require('#src/security/tokenAuthentication'); + const SU_MINTER = { + username: 'admin_minter', + active: true, + role: { role: 'super_user', id: 'su-id', permission: { super_user: true } }, + }; + const NON_SU_MINTER = { + username: 'basic_minter', + active: true, + role: { role: 'basic', id: 'basic-id', permission: { super_user: false } }, + }; + let scopedKeysPath; + + before(async () => { + scopedKeysPath = path.join(testUtils.getMockTestPath(), 'keys'); + fs.mkdirpSync(scopedKeysPath); + fs.writeFileSync(path.join(scopedKeysPath, '.jwtPass'), PASSPHRASE_VALUE); + fs.writeFileSync(path.join(scopedKeysPath, '.jwtPrivate.key'), PRIVATE_KEY_VALUE); + fs.writeFileSync(path.join(scopedKeysPath, '.jwtPublic.key'), PUBLIC_KEY_VALUE); + token_auth_plain.clearJWTRSAKeysCache(); + await user.setUsersWithRolesCache(new Map([['existing_user', { username: 'existing_user', active: true }]])); + }); + + after(() => { + fs.removeSync(scopedKeysPath); + token_auth_plain.clearJWTRSAKeysCache(); + }); + + function mint(overrides = {}) { + return token_auth_plain.createTokens({ + hdb_user: SU_MINTER, + role: { permission: { operations: ['read_only'] } }, + ...overrides, + }); + } + + it('non-super_user minter is rejected with 403', async () => { + let error; + try { + await mint({ hdb_user: NON_SU_MINTER }); + } catch (e) { + error = e; + } + assert.match(error.message, /super_user/); + assert.deepStrictEqual(error.statusCode, 403); + }); + + it('unauthenticated mint is rejected with 403', async () => { + let error; + try { + await mint({ hdb_user: undefined }); + } catch (e) { + error = e; + } + assert.deepStrictEqual(error.statusCode, 403); + }); + + it('happy path: single token, no refresh token, scoped-prefixed default attribution', async () => { + const result = await mint(); + assert.notDeepStrictEqual(result.operation_token, undefined); + assert.deepStrictEqual(result.refresh_token, undefined); + + const payload = jwt.decode(result.operation_token); + assert.deepStrictEqual(payload.sub, 'scoped-operation'); + assert.deepStrictEqual(payload.username, 'scoped:admin_minter'); + assert.deepStrictEqual(payload.minted_by, 'admin_minter'); + assert.deepStrictEqual(payload.super_user, false); + assert.deepStrictEqual(payload.role.permission.operations, ['read_only']); + }); + + it('username is arbitrary attribution and need not exist', async () => { + const result = await mint({ username: 'reporting-service' }); + const payload = jwt.decode(result.operation_token); + assert.deepStrictEqual(payload.username, 'reporting-service'); + assert.deepStrictEqual(payload.minted_by, 'admin_minter'); + }); + + it('an attribution username naming an existing user is rejected', async () => { + let error; + try { + await mint({ username: 'existing_user' }); + } catch (e) { + error = e; + } + assert.match(error.message, /must not name an existing user/); + }); + + it('embedded super_user/cluster_user are downgraded at mint', async () => { + const result = await mint({ + role: { permission: { super_user: true, cluster_user: true, operations: ['read_only'] } }, + }); + const payload = jwt.decode(result.operation_token); + assert.deepStrictEqual(payload.role.permission.super_user, false); + assert.deepStrictEqual(payload.role.permission.cluster_user, false); + }); + + it('password cannot be combined with an inline role', async () => { + let error; + try { + await mint({ password: 'pass' }); + } catch (e) { + error = e; + } + assert.match(error.message, /password/); + }); + + it('purpose cannot be combined with an inline role', async () => { + let error; + try { + await mint({ purpose: 'login' }); + } catch (e) { + error = e; + } + assert.match(error.message, /purpose/); + }); + + it('unknown operation names are rejected at mint', async () => { + let error; + try { + await mint({ role: { permission: { operations: ['totally_fake_op'] } } }); + } catch (e) { + error = e; + } + assert.match(error.message, /totally_fake_op/); + }); + + it('array permission is rejected at mint', async () => { + let error; + try { + await mint({ role: { permission: ['not-an-object'] } }); + } catch (e) { + error = e; + } + assert.notDeepStrictEqual(error, undefined); + }); + + it('malformed structure_user type is rejected at mint', async () => { + let error; + try { + await mint({ role: { permission: { structure_user: 'yes' } } }); + } catch (e) { + error = e; + } + assert.notDeepStrictEqual(error, undefined); + }); + + it('a permission object producing an oversized token is rejected at mint', async () => { + let error; + try { + // valid but huge: duplicate operation entries are legal, so only the size gate rejects this + await mint({ role: { permission: { operations: new Array(800).fill('search_by_value') } } }); + } catch (e) { + error = e; + } + assert.match(error.message, /exceeds/); + }); + + it('validateOperationToken accepts a scoped token and builds a synthetic user', async () => { + const { operation_token } = await mint({ username: 'svc' }); + const scopedUser = await token_auth_plain.validateOperationToken(operation_token); + assert.deepStrictEqual(scopedUser.username, 'svc'); + assert.deepStrictEqual(scopedUser.active, true); + assert.deepStrictEqual(scopedUser._scopedToken, true); + assert.deepStrictEqual(scopedUser._mintedBy, 'admin_minter'); + assert.match(scopedUser.role.role, /^_scoped_token_[0-9a-f]{24}$/); + assert.deepStrictEqual(scopedUser.role.permission.super_user, false); + assert.deepStrictEqual(scopedUser.role.permission.cluster_user, false); + assert(scopedUser.role.permission._expandedOperations instanceof Set); + assert(scopedUser.role.permission._expandedOperations.has('search_by_hash')); + assert(!scopedUser.role.permission._expandedOperations.has('insert')); + }); + + it('distinct permission sets get distinct synthetic role identities; identical sets share', async () => { + const tokenA = (await mint({ role: { permission: { operations: ['read_only'] } } })).operation_token; + const tokenB = (await mint({ role: { permission: { operations: ['standard_user'] } } })).operation_token; + const tokenC = (await mint({ role: { permission: { operations: ['read_only'] } }, username: 'other' })) + .operation_token; + const userA = await token_auth_plain.validateOperationToken(tokenA); + const userB = await token_auth_plain.validateOperationToken(tokenB); + const userC = await token_auth_plain.validateOperationToken(tokenC); + assert.notDeepStrictEqual(userA.role.role, userB.role.role); + assert.deepStrictEqual(userA.role.role, userC.role.role); + }); + + it('expired scoped token is rejected with 403', async () => { + const { operation_token } = await mint({ expires_in: '-1' }); + let error; + try { + await token_auth_plain.validateOperationToken(operation_token); + } catch (e) { + error = e; + } + assert.deepStrictEqual(error.message, 'token expired'); + assert.deepStrictEqual(error.statusCode, 403); + }); + + it('tampered scoped token is rejected', async () => { + const { operation_token } = await mint(); + const parts = operation_token.split('.'); + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8')); + payload.role.permission.operations = ['standard_user']; + parts[1] = Buffer.from(JSON.stringify(payload)).toString('base64url'); + let error; + try { + await token_auth_plain.validateOperationToken(parts.join('.')); + } catch (e) { + error = e; + } + assert.deepStrictEqual(error.statusCode, 401); + }); + + it('scoped token is not accepted as a refresh or login token', async () => { + const { operation_token } = await mint(); + for (const validator of [token_auth_plain.validateRefreshToken, token_auth_plain.validateLoginToken]) { + let error; + try { + await validator(operation_token); + } catch (e) { + error = e; + } + assert.deepStrictEqual(error.statusCode, 401); + } + }); + + it('legacy string-role tokens are still rejected by validateOperationToken', async () => { + const legacyToken = jwt.sign( + { username: 'existing_user', role: 'component_role' }, + { key: PRIVATE_KEY_VALUE, passphrase: PASSPHRASE_VALUE }, + { algorithm: 'RS256', subject: 'operation', expiresIn: '1d' } + ); + let error; + try { + await token_auth_plain.validateOperationToken(legacyToken); + } catch (e) { + error = e; + } + assert.deepStrictEqual(error.statusCode, 401); + }); + + it('a scoped-token bearer cannot self-mint standing tokens for its attribution name', async () => { + // the escalation this guards: attribution 'existing_user' does not exist at mint, a real + // user with that name is created later, and the passwordless self-mint path would otherwise + // resolve to that real user and hand out full operation/refresh tokens. + const scopedBearer = { + username: 'existing_user', + active: true, + _scopedToken: true, + role: { role: '_scoped_token_x', id: '_scoped_token_x', permission: { operations: ['read_only'] } }, + }; + let error; + try { + await token_auth_plain.createTokens({ hdb_user: scopedBearer }); + } catch (e) { + error = e; + } + assert.deepStrictEqual(error.statusCode, 401); + }); +}); + describe('test validateLoginToken function', () => { let rw_get_tokens; let jwt_spy; diff --git a/unitTests/server/serverHelpers/serverHandlers.test.js b/unitTests/server/serverHelpers/serverHandlers.test.js index 79468b1b67..de655f4a66 100644 --- a/unitTests/server/serverHelpers/serverHandlers.test.js +++ b/unitTests/server/serverHelpers/serverHandlers.test.js @@ -325,6 +325,18 @@ describe('Test serverHandlers.js module ', () => { }); }); + it('Should require auth for create auth tokens with an inline role object', async () => { + auth_stub.resolves(TEST_USER); + const test_req = testUtils.deepClone(TEST_AUTH_REQ); + test_req.body.username = 'attribution-only'; + test_req.body.role = { permission: { operations: ['read_only'] } }; + + await new Promise((resolve, reject) => + serverHandlers_rw.authHandler(test_req, {}, (err) => (err ? reject(err) : resolve())) + ); + assert.ok(test_req.body.hdb_user === TEST_USER, 'Scoped-token mint must carry the authenticated requester'); + }); + it('Should throw error if thrown from auth', () => { auth_stub.rejects(TEST_ERR); diff --git a/unitTests/utility/operation_authorization.test.js b/unitTests/utility/operation_authorization.test.js index b03df6f8ee..033cb9a897 100644 --- a/unitTests/utility/operation_authorization.test.js +++ b/unitTests/utility/operation_authorization.test.js @@ -1631,5 +1631,41 @@ describe('Test operations permissions', function () { const result = op_auth.verifyPerms(TEST_JSON, write.insert.name); assert.equal(result, null); }); + + it('allowlist gates structure_user: create_schema denied when not listed', function () { + const req_json = makeOpUserRequest(['read_only']); + req_json.hdb_user.role.permission.structure_user = true; + req_json.operation = terms.OPERATIONS_ENUM.CREATE_SCHEMA; + req_json.schema = 'newschema'; + delete req_json.table; + const result = op_auth.verifyPerms(req_json, schema.createSchema.name); + assert.notStrictEqual(result, null); + // create_schema resolves to its canonical api_name, create_database + assert.ok( + JSON.stringify(result).includes( + TEST_OPERATION_AUTH_ERROR.OP_NOT_IN_OPERATIONS(terms.OPERATIONS_ENUM.CREATE_DATABASE) + ) + ); + }); + + it('allowlist + structure_user: create_schema allowed when explicitly listed', function () { + const req_json = makeOpUserRequest([terms.OPERATIONS_ENUM.CREATE_DATABASE]); + req_json.hdb_user.role.permission.structure_user = true; + req_json.operation = terms.OPERATIONS_ENUM.CREATE_SCHEMA; + req_json.schema = 'newschema'; + delete req_json.table; + const result = op_auth.verifyPerms(req_json, schema.createSchema.name); + assert.equal(result, null); + }); + + it('allowlist gates super_user (inline-asserted role): unlisted op denied', function () { + const req_json = makeOpUserRequest(['read_only'], { insert: true }); + req_json.hdb_user.role.permission.super_user = true; + const result = op_auth.verifyPerms(req_json, write.insert.name); + assert.notStrictEqual(result, null); + assert.ok( + JSON.stringify(result).includes(TEST_OPERATION_AUTH_ERROR.OP_NOT_IN_OPERATIONS(terms.OPERATIONS_ENUM.INSERT)) + ); + }); }); }); diff --git a/utility/operation_authorization.ts b/utility/operation_authorization.ts index 1c09895e88..617b4eb1c1 100644 --- a/utility/operation_authorization.ts +++ b/utility/operation_authorization.ts @@ -430,6 +430,7 @@ requiredPermissions.set(terms.VALID_SQL_OPS_ENUM.UPDATE, new (permission as any) module.exports = { verifyPerms, verifyPermsAST, + verifyOperationsAllowlist, verifyBulkLoadAttributePerms, registerOperationPermission, unregisterOperationPermission, @@ -623,6 +624,28 @@ export function verifyPermsAST(ast, userObject, operation, apiOperation = terms. } } +/** + * Gate 1 of the role `operations` allowlist, callable from every authorization path. The SQL path + * (chooseOperation → checkASTPermissions) never reaches verifyPerms, so it must call this + * directly — otherwise an allowlisted role could reach unlisted operations through `sql`. + * Returns null when allowed (or no allowlist present), a PermissionResponseObject denial otherwise. + */ +export function verifyOperationsAllowlist(requestJson: any, operationFunctionName: string) { + const rolePermission = requestJson.hdb_user?.role?.permission; + const allowedOperationsList = rolePermission?.operations; + if (allowedOperationsList == null) return null; + // _expandedOperations is pre-built at cache-load time (O(1) lookup). + // Fall back to on-demand expansion for inline-asserted roles (e.g. impersonation via hdb_user in body). + const allowedOps = rolePermission._expandedOperations ?? expandOperationsPerms(allowedOperationsList); + // operationFunctionName is the internal camelCase function name; allowedOps contains snake_case + // API names. Resolve via the api_name stored on the permission entry (set at registration time). + const opApiName = requiredPermissions.get(operationFunctionName)?.api_name ?? operationFunctionName; + if (!allowedOps.has(opApiName)) { + return new PermissionResponseObject().handleUnauthorizedItem(HDB_ERROR_MSGS.OP_NOT_IN_OPERATIONS(opApiName)); + } + return null; +} + /** * Verifies permissions and restrictions for the NoSQL operation based on the user's assigned role. * @@ -679,6 +702,15 @@ export function verifyPerms(requestJson: any, operation: any, options?: { apiOpe return permsResponse.handleUnauthorizedItem(HDB_ERROR_MSGS.USER_HAS_NO_PERMS(requestJson.hdb_user?.username)); } + // Gate 1 of the optional `operations` allowlist: when present, only ops explicitly listed (or + // expanded from a group) are reachable. This must run before EVERY privilege early-return below + // (super_user, structure_user, system-table allowances) — otherwise a role combining one of + // those flags with a restrictive allowlist could reach ops outside its list. Gate 2 (the + // SU-only-op bypass for explicitly listed ops) stays below, after the ambient privilege checks. + const allowlistDenial = verifyOperationsAllowlist(requestJson, op); + if (allowlistDenial) return allowlistDenial; + const allowedOperationsList = requestJson.hdb_user?.role?.permission?.operations; + const isSuperUser = !!requestJson.hdb_user?.role?.permission?.super_user; const structureUser = requestJson.hdb_user?.role?.permission?.structure_user; // set to true if this operation affects a system table. Only su can read from system tables, but can't update/delete. @@ -729,36 +761,18 @@ export function verifyPerms(requestJson: any, operation: any, options?: { apiOpe ); } - // operations is an optional allowlist on the role. When present, it acts as a two-gate check: - // Gate 1 — operation allowlist: only ops explicitly listed (or expanded from a group) are reachable. - // Any unlisted op is denied here, before table CRUD checks even run. - // Gate 2 — SU bypass: if the op passed gate 1 and is normally restricted to super_user, the explicit - // listing is treated as a deliberate admin grant and allowed immediately (return null). - // Non-SU ops that pass gate 1 fall through to the normal table CRUD checks below. - const permission = requestJson.hdb_user?.role?.permission; - const operations = permission?.operations; - if (operations !== undefined) { - // _expandedOperations is pre-built at cache-load time (O(1) lookup). - // Fall back to on-demand expansion for inline-asserted roles (e.g. impersonation via hdb_user in body). - const allowedOps = permission._expandedOperations ?? expandOperationsPerms(operations); - // op is the internal camelCase function name; allowedOps contains snake_case API names. - // Resolve via the api_name stored on the permission entry (set at registration time). - const opApiName = requiredPermissions.get(op)?.api_name ?? op; - // Gate 1: op not in allowlist — deny regardless of table CRUD permissions. - if (!allowedOps.has(opApiName)) { - return permsResponse.handleUnauthorizedItem(HDB_ERROR_MSGS.OP_NOT_IN_OPERATIONS(opApiName)); - } - // Gate 2: op is SU-only but was explicitly granted via operations — allow without super_user. - // Without this, the SU check further below would still deny it even though it passed gate 1. - // TODO: ops registered with both requires_su AND non-empty CRUD perms have their table-level - // CRUD check bypassed here. Should fall through for those instead of returning null - // unconditionally. The managed-backup ops share this shape but self-enforce super_user in their - // handlers/validators (dataLayer/rocksdbBackup.ts requireSuperUser), so they are not delegable - // regardless; get_backup remains the one that relies solely on this gate. Low risk today but - // worth tightening. - if (requiredPermissions.get(op)?.requires_su) { - return null; - } + // Gate 2 of the `operations` allowlist (gate 1 ran above, before the privilege early-returns): + // the op passed the allowlist, so if it is normally restricted to super_user, the explicit + // listing is treated as a deliberate admin grant and allowed immediately (return null). + // Non-SU ops that passed gate 1 fall through to the normal table CRUD checks below. + // TODO: ops registered with both requires_su AND non-empty CRUD perms have their table-level + // CRUD check bypassed here. Should fall through for those instead of returning null + // unconditionally. The managed-backup ops share this shape but self-enforce super_user in their + // handlers/validators (dataLayer/rocksdbBackup.ts requireSuperUser), so they are not delegable + // regardless; get_backup remains the one that relies solely on this gate. Low risk today but + // worth tightening. + if (allowedOperationsList !== undefined && requiredPermissions.get(op)?.requires_su) { + return null; } const fullRolePerms = permsTranslator.getRolePermissions(requestJson.hdb_user?.role);