feat(device-core): add idempotent project management
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
assertActorCanManageOwnerScope,
|
||||
assertGrantMutationAllowed,
|
||||
assertProjectCapability,
|
||||
normalizeManagementActor,
|
||||
normalizeManagementCommand,
|
||||
resolveProjectAccess,
|
||||
} from "../src/project-management.mjs";
|
||||
|
||||
const projectRef = "project:11111111-1111-4111-8111-111111111111";
|
||||
|
||||
test("normalizes strict generic management commands without seeded entities", () => {
|
||||
const project = normalizeManagementCommand("project.ensure", {
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
projectKey: "field-devices",
|
||||
name: "Field Devices",
|
||||
description: "Generic project",
|
||||
});
|
||||
assert.deepEqual(project, {
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
projectKey: "field-devices",
|
||||
name: "Field Devices",
|
||||
description: "Generic project",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => normalizeManagementCommand("project.ensure", {
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
projectKey: "field-devices",
|
||||
name: "Field Devices",
|
||||
rawPayload: "forbidden",
|
||||
}),
|
||||
/device_management_command_field_unexpected:rawPayload/,
|
||||
);
|
||||
});
|
||||
|
||||
test("company scope requires an asserted scope and Hub admin ceiling", () => {
|
||||
const scope = { scopeKind: "company", ownerRef: "client:example" };
|
||||
assert.doesNotThrow(() => assertActorCanManageOwnerScope(actor({
|
||||
hubRole: "admin",
|
||||
ownerScopes: [scope],
|
||||
}), scope));
|
||||
assert.throws(
|
||||
() => assertActorCanManageOwnerScope(actor({
|
||||
hubRole: "viewer",
|
||||
ownerScopes: [scope],
|
||||
}), scope),
|
||||
/device_owner_scope_access_denied/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertActorCanManageOwnerScope(actor({ hubRole: "owner" }), scope),
|
||||
/device_owner_scope_access_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
test("personal scope is isolated to the matching Hub user", () => {
|
||||
const scope = { scopeKind: "personal", ownerRef: "user:engineer" };
|
||||
assert.doesNotThrow(() => assertActorCanManageOwnerScope(
|
||||
actor({ hubRole: "owner" }),
|
||||
scope,
|
||||
));
|
||||
assert.throws(
|
||||
() => assertActorCanManageOwnerScope(
|
||||
actor({ hubRole: "member" }),
|
||||
scope,
|
||||
),
|
||||
/device_owner_scope_access_denied/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertActorCanManageOwnerScope(
|
||||
actor({ userRef: "user:other", hubRole: "owner" }),
|
||||
scope,
|
||||
),
|
||||
/device_owner_scope_access_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Hub owner has no project access without an explicit project grant", () => {
|
||||
const access = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "owner" }),
|
||||
grants: [],
|
||||
});
|
||||
assert.equal(access.allowed, false);
|
||||
assert.deepEqual(access.capabilities, []);
|
||||
});
|
||||
|
||||
test("a direct user grant overrides broader group grants", () => {
|
||||
const access = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "owner", groupRefs: ["group:admins"] }),
|
||||
grants: [
|
||||
grant({
|
||||
grantRef: "grant:group-admin",
|
||||
principalKind: "group",
|
||||
principalRef: "group:admins",
|
||||
projectRole: "admin",
|
||||
}),
|
||||
grant({
|
||||
grantRef: "grant:direct-viewer",
|
||||
principalKind: "user",
|
||||
principalRef: "user:engineer",
|
||||
projectRole: "viewer",
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(access.projectRole, "viewer");
|
||||
assert.equal(access.capabilities.includes("access.manage"), false);
|
||||
});
|
||||
|
||||
test("matching group grants combine bounded operator and engineer capabilities", () => {
|
||||
const access = resolveProjectAccess({
|
||||
actor: actor({
|
||||
hubRole: "member",
|
||||
groupRefs: ["group:operators", "group:engineers"],
|
||||
}),
|
||||
grants: [
|
||||
grant({
|
||||
grantRef: "grant:operator",
|
||||
principalKind: "group",
|
||||
principalRef: "group:operators",
|
||||
projectRole: "operator",
|
||||
}),
|
||||
grant({
|
||||
grantRef: "grant:engineer",
|
||||
principalKind: "group",
|
||||
principalRef: "group:engineers",
|
||||
projectRole: "engineer",
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(access.capabilities.includes("device.enroll"), true);
|
||||
assert.equal(access.capabilities.includes("command.dispatch"), true);
|
||||
assert.equal(access.capabilities.includes("access.manage"), false);
|
||||
});
|
||||
|
||||
test("Hub ceiling and explicit deny prevent privilege escalation", () => {
|
||||
const ownerGrant = grant({
|
||||
grantRef: "grant:owner",
|
||||
principalKind: "user",
|
||||
principalRef: "user:engineer",
|
||||
projectRole: "owner",
|
||||
capabilityDeny: ["credential.manage"],
|
||||
});
|
||||
const hubAdmin = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "admin" }),
|
||||
grants: [ownerGrant],
|
||||
});
|
||||
const hubOwner = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "owner" }),
|
||||
grants: [ownerGrant],
|
||||
});
|
||||
|
||||
assert.equal(hubAdmin.capabilities.includes("device.transfer"), false);
|
||||
assert.equal(hubOwner.capabilities.includes("device.transfer"), true);
|
||||
assert.equal(hubOwner.capabilities.includes("credential.manage"), false);
|
||||
});
|
||||
|
||||
test("owner grant mutations require both Hub and project ownership authority", () => {
|
||||
const grants = [grant({
|
||||
grantRef: "grant:owner",
|
||||
principalKind: "user",
|
||||
principalRef: "user:engineer",
|
||||
projectRole: "owner",
|
||||
})];
|
||||
const ownerCommand = normalizeManagementCommand("project_grant.upsert", {
|
||||
projectRef,
|
||||
principalKind: "user",
|
||||
principalRef: "user:second-owner",
|
||||
projectRole: "owner",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertGrantMutationAllowed(
|
||||
actor({ hubRole: "admin" }),
|
||||
grants,
|
||||
ownerCommand,
|
||||
),
|
||||
/device_project_owner_transfer_denied/,
|
||||
);
|
||||
assert.doesNotThrow(() => assertGrantMutationAllowed(
|
||||
actor({ hubRole: "owner" }),
|
||||
grants,
|
||||
ownerCommand,
|
||||
));
|
||||
});
|
||||
|
||||
test("grant normalization rejects group owners and capability overlap", () => {
|
||||
assert.throws(
|
||||
() => normalizeManagementCommand("project_grant.upsert", {
|
||||
projectRef,
|
||||
principalKind: "group",
|
||||
principalRef: "group:owners",
|
||||
projectRole: "owner",
|
||||
}),
|
||||
/device_project_owner_must_be_user/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeManagementCommand("project_grant.upsert", {
|
||||
projectRef,
|
||||
principalKind: "user",
|
||||
principalRef: "user:operator",
|
||||
projectRole: "operator",
|
||||
capabilityAllow: ["command.dispatch"],
|
||||
capabilityDeny: ["command.dispatch"],
|
||||
}),
|
||||
/device_project_capability_overlap/,
|
||||
);
|
||||
});
|
||||
|
||||
test("capability checks fail closed for inactive or unrelated grants", () => {
|
||||
assert.throws(
|
||||
() => assertProjectCapability(
|
||||
actor({ hubRole: "owner" }),
|
||||
[grant({ lifecycleState: "revoked" })],
|
||||
"project.read",
|
||||
),
|
||||
/device_project_capability_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
test("denying project.read collapses every derived capability", () => {
|
||||
const access = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "owner" }),
|
||||
grants: [grant({
|
||||
projectRole: "owner",
|
||||
capabilityDeny: ["project.read"],
|
||||
})],
|
||||
});
|
||||
|
||||
assert.equal(access.allowed, false);
|
||||
assert.deepEqual(access.capabilities, []);
|
||||
assert.throws(
|
||||
() => assertProjectCapability(
|
||||
actor({ hubRole: "owner" }),
|
||||
[grant({
|
||||
projectRole: "owner",
|
||||
capabilityDeny: ["project.read"],
|
||||
})],
|
||||
"access.manage",
|
||||
),
|
||||
/device_project_capability_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
function actor(overrides = {}) {
|
||||
return normalizeManagementActor({
|
||||
userRef: "user:engineer",
|
||||
hubRole: "member",
|
||||
groupRefs: [],
|
||||
ownerScopes: [],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function grant(overrides = {}) {
|
||||
return {
|
||||
grantRef: "grant:default",
|
||||
principalKind: "user",
|
||||
principalRef: "user:engineer",
|
||||
projectRole: "viewer",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user