fix(runtime): apply permission reloads atomically
This commit is contained in:
parent
072ce5aede
commit
3ae7109faa
|
|
@ -232,6 +232,46 @@ export class PermissionAuthority {
|
|||
return ok(null);
|
||||
}
|
||||
|
||||
/** Atomically replace reloadable ceilings and persisted grants. */
|
||||
async replaceConfiguredPolicy(configuration: {
|
||||
readonly ceilings: readonly PermissionCeiling[];
|
||||
readonly administratorGrants: readonly PermissionGrant[];
|
||||
readonly projectGrants: readonly PermissionGrant[];
|
||||
}): Promise<Result<null, PermissionPathError>> {
|
||||
const ceilings = await canonicalizePermissionCeilings(
|
||||
configuration.ceilings,
|
||||
);
|
||||
if (!ceilings.ok) return ceilings;
|
||||
const grants: PermissionGrant[] = [];
|
||||
for (const grant of [
|
||||
...configuration.administratorGrants,
|
||||
...configuration.projectGrants,
|
||||
]) {
|
||||
const canonical = await canonicalizeGrant(grant);
|
||||
if (!canonical.ok) return canonical;
|
||||
grants.push(canonical.value);
|
||||
}
|
||||
let candidate: PermissionPolicy = {
|
||||
...reloadPermissionCeilings(this.policy, ceilings.value),
|
||||
grants: this.policy.grants.filter(
|
||||
({ lifetime }) =>
|
||||
lifetime !== "administrator" && lifetime !== "project",
|
||||
),
|
||||
};
|
||||
for (const grant of grants) {
|
||||
const added = grantPermission(candidate, grant);
|
||||
if (!added.ok)
|
||||
return err(
|
||||
new PermissionPathError(grant.grant_id, "io", {
|
||||
cause: added.error,
|
||||
}),
|
||||
);
|
||||
candidate = added.value;
|
||||
}
|
||||
this.policy = candidate;
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
/** Replace ceilings without restarting the MCP process. */
|
||||
async reload(
|
||||
ceilings: readonly PermissionCeiling[],
|
||||
|
|
|
|||
47
src/main.ts
47
src/main.ts
|
|
@ -17,6 +17,7 @@ import { readProjectPermissionStore } from "./application/ProjectPermissionStore
|
|||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
|
||||
import { CdpElectronProvider } from "./browser/CdpElectronProvider.js";
|
||||
import type { PermissionGrant } from "./domain/permissionPolicy.js";
|
||||
|
||||
const MCP_CONNECTION_LOST =
|
||||
"REA lost its MCP client connection. Restart REA from your MCP client.";
|
||||
|
|
@ -172,25 +173,7 @@ export const run = async (
|
|||
serverLogger.error("Reloaded permission policy is invalid");
|
||||
return;
|
||||
}
|
||||
const reloaded = await permissionAuthority.value.reload(
|
||||
refreshed.value.permissionCeilings,
|
||||
);
|
||||
if (!reloaded.ok) {
|
||||
serverLogger.error(
|
||||
"Reloaded permission ceiling could not be applied",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const administrators =
|
||||
await permissionAuthority.value.replaceAdministratorGrants(
|
||||
refreshed.value.administratorPermissionGrants,
|
||||
);
|
||||
if (!administrators.ok) {
|
||||
serverLogger.error(
|
||||
"Reloaded administrator grants could not be applied",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let projectGrants: readonly PermissionGrant[] = [];
|
||||
if (
|
||||
refreshed.value.permissionProjectRoot !== undefined &&
|
||||
refreshed.value.permissionProjectStore !== undefined
|
||||
|
|
@ -203,21 +186,17 @@ export const run = async (
|
|||
serverLogger.error("Reloaded project grants could not be read");
|
||||
return;
|
||||
}
|
||||
const projects = await permissionAuthority.value.replaceProjectGrants(
|
||||
project.value?.grants ?? [],
|
||||
);
|
||||
if (!projects.ok) {
|
||||
serverLogger.error("Reloaded project grants could not be applied");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const projects = await permissionAuthority.value.replaceProjectGrants(
|
||||
[],
|
||||
);
|
||||
if (!projects.ok) {
|
||||
serverLogger.error("Reloaded project grants could not be cleared");
|
||||
return;
|
||||
}
|
||||
projectGrants = project.value?.grants ?? [];
|
||||
}
|
||||
const reloaded =
|
||||
await permissionAuthority.value.replaceConfiguredPolicy({
|
||||
ceilings: refreshed.value.permissionCeilings,
|
||||
administratorGrants: refreshed.value.administratorPermissionGrants,
|
||||
projectGrants,
|
||||
});
|
||||
if (!reloaded.ok) {
|
||||
serverLogger.error("Reloaded permission policy could not be applied");
|
||||
return;
|
||||
}
|
||||
currentConfig = refreshed.value;
|
||||
Object.assign(
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
canonicalizePermissionCeilings,
|
||||
canonicalizePermissionRequest,
|
||||
createPermissionAuthority,
|
||||
type PermissionAuthority,
|
||||
} from "../src/application/PermissionAuthority.js";
|
||||
import {
|
||||
createPermissionPolicy,
|
||||
evaluatePermission,
|
||||
type PermissionGrant,
|
||||
type PermissionScope,
|
||||
} from "../src/domain/permissionPolicy.js";
|
||||
|
||||
describe("permission authority", () => {
|
||||
|
|
@ -99,4 +103,79 @@ describe("permission authority", () => {
|
|||
if (result.ok)
|
||||
expect(result.value.roots).toEqual([join(sandbox, "new.json")]);
|
||||
});
|
||||
|
||||
it("commits a configured policy only after every grant validates", async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), "rea-permission-reload-"));
|
||||
const oldRoot = join(sandbox, "old");
|
||||
const newRoot = join(sandbox, "new");
|
||||
const outsideRoot = join(sandbox, "outside");
|
||||
await Promise.all([mkdir(oldRoot), mkdir(newRoot), mkdir(outsideRoot)]);
|
||||
const oldScope = evidenceScope(oldRoot);
|
||||
const newScope = evidenceScope(newRoot);
|
||||
const authority = await createPermissionAuthority(
|
||||
[oldScope],
|
||||
[administratorGrant(oldScope)],
|
||||
);
|
||||
expect(authority.ok).toBe(true);
|
||||
if (!authority.ok) return;
|
||||
|
||||
const reloaded = await authority.value.replaceConfiguredPolicy({
|
||||
ceilings: [newScope],
|
||||
administratorGrants: [administratorGrant(newScope)],
|
||||
projectGrants: [
|
||||
{
|
||||
...evidenceScope(outsideRoot),
|
||||
grant_id: "project:outside-ceiling",
|
||||
lifetime: "project",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(reloaded.ok).toBe(false);
|
||||
expect(await canRead(authority.value, oldRoot)).toBe(true);
|
||||
expect(await canRead(authority.value, newRoot)).toBe(false);
|
||||
|
||||
expect(
|
||||
await authority.value.replaceConfiguredPolicy({
|
||||
ceilings: [newScope],
|
||||
administratorGrants: [administratorGrant(newScope)],
|
||||
projectGrants: [],
|
||||
}),
|
||||
).toEqual({ ok: true, value: null });
|
||||
expect(await canRead(authority.value, oldRoot)).toBe(false);
|
||||
expect(await canRead(authority.value, newRoot)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const evidenceScope = (root: string): PermissionScope => ({
|
||||
capability: "evidence_read",
|
||||
roots: [root],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
});
|
||||
|
||||
const administratorGrant = (scope: PermissionScope): PermissionGrant => ({
|
||||
...scope,
|
||||
grant_id: "administrator:evidence_read",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
});
|
||||
|
||||
const canRead = async (
|
||||
authority: PermissionAuthority,
|
||||
root: string,
|
||||
): Promise<boolean> =>
|
||||
(
|
||||
await authority.explain(
|
||||
{
|
||||
...evidenceScope(root),
|
||||
operation_identity: `read:${root}`,
|
||||
},
|
||||
"read",
|
||||
)
|
||||
).ok;
|
||||
|
|
|
|||
Loading…
Reference in New Issue