Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<minter>`): 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
Expand Down
4 changes: 4 additions & 0 deletions components/mcp/listChanged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ function snapshotSessions(profile: McpProfile): RegisteredSession[] {
}

async function refreshSessionUser(record: RegisteredSession): Promise<void> {
// 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;
}
Expand Down
2 changes: 2 additions & 0 deletions components/mcp/toolRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down
4 changes: 2 additions & 2 deletions components/mcp/tools/schemas/operationDescriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,9 @@ export const OPERATION_DESCRIPTIONS: Record<string, string> = {
// 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:<minter>'); 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.',
Expand Down
184 changes: 184 additions & 0 deletions integrationTests/apiTests/token-auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ suite('Token authentication', (ctx) => {
let admin;
let operationToken;
let refreshToken;
let scopedToken;
let authorizeLocal;

before(async () => {
Expand Down Expand Up @@ -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);
});
});
82 changes: 82 additions & 0 deletions integrationTests/security/subscription-revocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
Loading
Loading