294 lines
8.1 KiB
JavaScript
294 lines
8.1 KiB
JavaScript
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("configuration mutation belongs to engineer and admin, not operator", () => {
|
|
const engineer = resolveProjectAccess({
|
|
actor: actor({ groupRefs: ["group:engineers"] }),
|
|
grants: [grant({
|
|
principalKind: "group",
|
|
principalRef: "group:engineers",
|
|
projectRole: "engineer",
|
|
})],
|
|
});
|
|
const operator = resolveProjectAccess({
|
|
actor: actor({ groupRefs: ["group:operators"] }),
|
|
grants: [grant({
|
|
principalKind: "group",
|
|
principalRef: "group:operators",
|
|
projectRole: "operator",
|
|
})],
|
|
});
|
|
|
|
assert.equal(engineer.capabilities.includes("configuration.manage"), true);
|
|
assert.equal(operator.capabilities.includes("configuration.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,
|
|
};
|
|
}
|