Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 9 additions & 4 deletions components/mcp/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,17 @@ A handful of design points are non-obvious and easy to break:
1. **The `operations` allowlist.** `verifyOperationsAllowlist` runs _ahead of every privilege
early-return_ in `verifyPerms` (harper#2176), super_user and structure_user included, so a
helper that short-circuits on a privilege flag advertises tools that fail closed on call.
2. **The `api_name` alias.** Dispatch tests the handler's canonical `api_name`, and four ops are
2. **The `api_name` alias.** Dispatch tests the handler's canonical `api_name`, and eight ops are
published under a different name — `create_schema`/`drop_schema` (handlers
`createSchema`/`dropSchema`, api_names `create_database`/`drop_database`),
`describe_database`→`describe_schema`, `search_by_id`→`search_by_hash`. Matching the raw tool
name disagrees in BOTH directions. The alias table is hand-maintained because
`OPERATION_FUNCTION_MAP` pulls in the server and cannot be imported here.
`describe_database`→`describe_schema`, `search_by_id`→`search_by_hash`, and the legacy
`add_`/`package_`/`deploy_custom_function_project` → `add_`/`package_`/`deploy_component`,
`delete_records_before`→`delete_files_before`. Matching the raw tool name disagrees in BOTH
directions. The alias table is hand-maintained because `OPERATION_FUNCTION_MAP` pulls in the
server and cannot be imported here; `unitTests/utility/operation_authorization.test.js` compares
discovery with gate 1 for every dispatched operation, so a drift fails there. Discovery still
advertises `get_backup`, `read_transaction_log` and `catchup` to a role that lists them, while
dispatch refuses them (their registrations deliberately carry a `null` `api_name`).
3. **`structure_user` is not one grant.** `STRUCTURE_USER_OPS` holds only the four
table/attribute ops; create/drop schema-or-database needs `structure_user === true`, so an
array grant (and `[]`, which is truthy) is denied for those four.
Expand Down
4 changes: 4 additions & 0 deletions components/mcp/operationVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const OPERATION_API_NAME_ALIASES = new Map([
['drop_schema', 'drop_database'],
['describe_database', 'describe_schema'],
['search_by_id', 'search_by_hash'],
['add_custom_function_project', 'add_component'],
['package_custom_function_project', 'package_component'],
['deploy_custom_function_project', 'deploy_component'],
['delete_records_before', 'delete_files_before'],
]);

const STRUCTURE_TABLE_OPERATIONS = new Set(['create_table', 'drop_table', 'create_attribute', 'drop_attribute']);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Deployed by operation-user-rbac.test.ts as a role that is only granted deploy_component; registers nothing.
92 changes: 91 additions & 1 deletion integrationTests/server/operation-user-rbac.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
*/
import { suite, test, before, after } from 'node:test';
import { strictEqual, ok } from 'node:assert';
import { resolve } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';

import { startHarper, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';
import { startHarper, teardownHarper, targz, type ContextWithHarper } from '@harperfast/integration-testing';

const DATABASE = 'test_db';
const TABLE = 'dogs';
Expand All @@ -33,6 +35,12 @@ const STANDARD_USER_ROLE = 'standard_user_ops_role';
const STANDARD_USER_USER = 'standard_user_user';
const STANDARD_USER_PASS = 'Test1234!';

const DEPLOYER_ROLE = 'deploy_ops_role';
const DEPLOYER_USER = 'deployer_user';
const DEPLOYER_PASS = 'Test1234!';
const DEPLOY_FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures/operation-rbac-deploy');
const DEPLOY_PROJECT = 'rbac-deploy-app';

suite('operations RBAC', (ctx: ContextWithHarper) => {
before(async () => {
await startHarper(ctx, { config: {}, env: {} });
Expand Down Expand Up @@ -132,6 +140,13 @@ suite('operations RBAC', (ctx: ContextWithHarper) => {
},
});

const deployerRole = await op({
operation: 'add_role',
role: DEPLOYER_ROLE,
permission: { super_user: false, operations: ['deploy_component'] },
});
strictEqual(deployerRole.status, 200, `add_role ${DEPLOYER_ROLE}: ${await deployerRole.text()}`);

// Create test users
await op({
operation: 'add_user',
Expand All @@ -155,6 +170,14 @@ suite('operations RBAC', (ctx: ContextWithHarper) => {
password: STANDARD_USER_PASS,
active: true,
});
const deployerUser = await op({
operation: 'add_user',
role: DEPLOYER_ROLE,
username: DEPLOYER_USER,
password: DEPLOYER_PASS,
active: true,
});
strictEqual(deployerUser.status, 200, `add_user ${DEPLOYER_USER}: ${await deployerUser.text()}`);
});

after(async () => {
Expand All @@ -178,6 +201,11 @@ suite('operations RBAC', (ctx: ContextWithHarper) => {
});
}

function assertNotInOperations(body: any, operation: string) {
const expected = `Operation '${operation}' is not permitted for this role's operations configuration`;
ok(body?.unauthorized_access?.includes(expected), `expected "${expected}", got ${JSON.stringify(body)}`);
}

// -- read_only_ops_role tests --

suite('read_only_ops_role', () => {
Expand Down Expand Up @@ -304,6 +332,68 @@ suite('operations RBAC', (ctx: ContextWithHarper) => {
});
strictEqual(res.status, 403);
});

test('deploy_component is denied (SU-only op not in operations list)', async () => {
const res = await callOp(SU_OPS_USER, SU_OPS_PASS, {
operation: 'deploy_component',
project: DEPLOY_PROJECT,
payload: await targz(DEPLOY_FIXTURE_PATH),
restart: false,
});
strictEqual(res.status, 403);
assertNotInOperations(await res.json(), 'deploy_component');
});
});

// -- deploy_ops_role tests --

suite('deploy_ops_role', () => {
test('deploy_component is allowed (SU-only op granted via operations)', async () => {
const res = await callOp(DEPLOYER_USER, DEPLOYER_PASS, {
operation: 'deploy_component',
project: DEPLOY_PROJECT,
payload: await targz(DEPLOY_FIXTURE_PATH),
restart: false,
});
const body = (await res.json()) as any;
strictEqual(res.status, 200, `deploy_component: ${JSON.stringify(body)}`);
strictEqual(body.message, `Successfully deployed: ${DEPLOY_PROJECT}`);

const deadline = Date.now() + 20_000;
let deployment: any;
while (Date.now() < deadline) {
const got = await callOp(ctx.harper.admin.username, ctx.harper.admin.password, {
operation: 'get_deployment',
deployment_id: body.deployment_id,
});
deployment = await got.json();
if (got.status === 200 && (deployment.status === 'success' || deployment.status === 'failed')) break;
await sleep(100);
}
strictEqual(deployment?.status, 'success', `get_deployment: ${JSON.stringify(deployment)}`);
strictEqual(deployment.user, DEPLOYER_USER);
});

test('deploy_custom_function_project is allowed (legacy alias of the granted deploy_component)', async () => {
const res = await callOp(DEPLOYER_USER, DEPLOYER_PASS, {
operation: 'deploy_custom_function_project',
project: DEPLOY_PROJECT,
payload: await targz(DEPLOY_FIXTURE_PATH),
restart: false,
});
const body = (await res.json()) as any;
strictEqual(res.status, 200, `deploy_custom_function_project: ${JSON.stringify(body)}`);
strictEqual(body.message, `Successfully deployed: ${DEPLOY_PROJECT}`);
});

test('drop_component is denied (the grant is deploy_component only)', async () => {
const res = await callOp(DEPLOYER_USER, DEPLOYER_PASS, {
operation: 'drop_component',
project: DEPLOY_PROJECT,
});
strictEqual(res.status, 403);
assertNotInOperations(await res.json(), 'drop_component');
});
});

// -- role validation tests --
Expand Down
16 changes: 15 additions & 1 deletion security/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Index of every design note: [DESIGN.md](../DESIGN.md).

## OIDC trusted publishing (`security/authn/oidc/`)

`exchange_oidc_token` lets a workload authenticate with no stored Harper credential (#2171): it presents an identity token minted by its runtime, and gets back a one-hour operation token for the user a stored trust policy names. It is in `NO_AUTH_OPERATIONS` because it _is_ the authentication, the same way `create_authentication_tokens` is against a password — the same three wiring points apply (`serverHandlers.js` `NO_AUTH_OPERATIONS`, the `verifyPerms` bypass in `serverUtilities.ts`, and a `permission(false, [])` registration).
`exchange_oidc_token` lets a workload authenticate with no stored Harper credential (#2171): it presents an identity token minted by its runtime, and gets back a one-hour operation token for the user a stored trust policy names. It is in `NO_AUTH_OPERATIONS` because it _is_ the authentication, the same way `create_authentication_tokens` is against a password — the same three wiring points apply (`serverHandlers.js` `NO_AUTH_OPERATIONS`, the `verifyPerms` bypass in `serverUtilities.ts`, and a `permission(false, [], OPERATIONS_ENUM.EXCHANGE_OIDC_TOKEN)` registration).

**The core is issuer-agnostic; everything issuer-specific lives in `providers/`.** That split is the point of the layout, not an accident of it — a new workload-identity issuer should be a profile, not a change to verification, matching, or storage.

Expand Down Expand Up @@ -67,6 +67,20 @@ on translated table CRUD permissions only. A scoped token intended to be read-on
endpoints must carry restrictive table permissions; `operations: ['read_only']` alone does not
constrain REST writes if table perms allow them.

Which name the allowlist is checked against: `verifyPerms` is handed the handler, not the invoked
operation, so gate 1 checks the registered entry's `api_name`, and never the handler's own name —
only a name with no registration at all (`sql`) is checked as given. The `permission` constructor
requires that argument: an API name, or `null` when no allowlist may grant the operation. Leaving it
out is a compile error, which closes the old failure where an omitted name made gate 1 fall back to
the handler name — refusing every role that listed the operation, unless the handler name happened
to equal the API name, when it silently granted it instead.
Aliases share a handler, so listing the canonical name grants both spellings and the alias spelling
grants neither. `get_backup`, `read_transaction_log` and `catchup` are registered with `null`: gate 2
would grant `get_backup` ahead of its READ check on a whole-database copy, `read_transaction_log`
lacks `read_audit_log`'s `system.hdb_secret` guard, and the legacy `catchup` applies writes to any
table with no table permission check. A test in `unitTests/utility/operation_authorization.test.js`
holds every dispatched operation to this.

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
Expand Down
5 changes: 5 additions & 0 deletions unitTests/components/mcp/toolRegistry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -456,9 +456,14 @@ describe('mcp/toolRegistry', () => {
assert.equal(listed(['drop_database'], 'drop_schema'), true);
assert.equal(listed(['describe_schema'], 'describe_database'), true);
assert.equal(listed(['search_by_hash'], 'search_by_id'), true);
assert.equal(listed(['add_component'], 'add_custom_function_project'), true);
assert.equal(listed(['package_component'], 'package_custom_function_project'), true);
assert.equal(listed(['deploy_component'], 'deploy_custom_function_project'), true);
assert.equal(listed(['delete_files_before'], 'delete_records_before'), true);
// The alias name is not what dispatch tests, so it grants neither.
assert.equal(listed(['create_schema'], 'create_schema'), false);
assert.equal(listed(['drop_schema'], 'drop_schema'), false);
assert.equal(listed(['deploy_custom_function_project'], 'deploy_component'), false);
});

it('an array structure_user does not reach the database-level structure ops', () => {
Expand Down
31 changes: 28 additions & 3 deletions unitTests/security/tokenOperationScope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ describe('token-scoped operation narrowing', () => {
// chooseOperation. A gate in only one of them lets a token scoped to e.g. get_status run arbitrary
// SQL against whatever its role can reach — which would falsify the whole "can only subtract" claim.
// The scope is written in API-operation names (`deploy_component`), which is what the caller sends as
// `operation`. verifyPerms must gate on that, not on the handler function name — deployComponent has
// no api_name mapping, so gating on the handler denied the feature's own headline operation.
// `operation`. verifyPerms must gate on that, not on the handler function name — aliases share a
// handler, and its api_name names only one of them.
describe('token scope gates on the API operation, not the handler name', () => {
function requestFor(operation, tokenOperations) {
const hdb_user = { username: 'ci-deploy', role: { role: 'r', permission: { super_user: true } } };
Expand All @@ -104,7 +104,6 @@ describe('token scope gates on the API operation, not the handler name', () => {
}

it('allows deploy_component when the scope names it', () => {
// deployComponent (the handler) has no api_name; the scope names the API op `deploy_component`.
const result = opAuth.verifyPerms(requestFor('deploy_component', ['deploy_component']), 'deployComponent');
assert.ok(isAllowed(result), 'a token scoped to deploy_component must be able to deploy_component');
});
Expand Down Expand Up @@ -142,6 +141,32 @@ describe('token scope gates on the API operation, not the handler name', () => {
});
});

// A trust policy pointed at a least-privilege CI user: the role grants one SU-only operation through its
// allowlist, which gate 2 returns early for, so the scope has to have been applied before that.
describe('token scope over a role that is granted deploy_component', () => {
function deployAs(tokenOperations) {
const permission = { super_user: false, operations: ['deploy_component'] };
const hdb_user = { username: 'ci-deploy', role: { role: 'ci_deploy', permission }, tokenOperations };
return opAuth.verifyPerms({ operation: 'deploy_component', hdb_user }, 'deployComponent');
}

it('deploys when the scope names deploy_component', () => {
assert.ok(isAllowed(deployAs(['deploy_component'])));
});

it('deploys when the policy carries no scope', () => {
assert.ok(isAllowed(deployAs(null)));
});

it('does not deploy when the scope names something else', () => {
assert.ok(!isAllowed(deployAs(['get_status'])));
});

it('does not deploy when the scope is empty', () => {
assert.ok(!isAllowed(deployAs([])));
});
});

describe('token-scoped narrowing on the SQL path', () => {
function userWithScope(permission, tokenOperations) {
const user = { username: 'ci-deploy', role: { role: '_tokenScope_test', permission } };
Expand Down
Loading
Loading