feat(device-core): add idempotent project management
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
|
||||
"project.read",
|
||||
"project.manage",
|
||||
"access.manage",
|
||||
"inventory.read",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
"collection.manage",
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
"credential.manage",
|
||||
"audit.read",
|
||||
]);
|
||||
|
||||
export const DEVICE_PROJECT_ROLES = Object.freeze([
|
||||
"viewer",
|
||||
"operator",
|
||||
"engineer",
|
||||
"admin",
|
||||
"owner",
|
||||
]);
|
||||
|
||||
export const DEVICE_HUB_ROLES = Object.freeze([
|
||||
"viewer",
|
||||
"member",
|
||||
"admin",
|
||||
"owner",
|
||||
]);
|
||||
|
||||
export const DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
||||
"owner_scope.ensure",
|
||||
"project.ensure",
|
||||
"collection.ensure",
|
||||
"project_grant.upsert",
|
||||
]);
|
||||
|
||||
const capabilitySet = new Set(DEVICE_PROJECT_CAPABILITIES);
|
||||
const projectRoleSet = new Set(DEVICE_PROJECT_ROLES);
|
||||
const hubRoleSet = new Set(DEVICE_HUB_ROLES);
|
||||
const commandKindSet = new Set(DEVICE_MANAGEMENT_COMMAND_KINDS);
|
||||
const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/;
|
||||
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const projectRefPattern = /^project:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;
|
||||
|
||||
const roleCapabilities = Object.freeze({
|
||||
viewer: Object.freeze([
|
||||
"project.read",
|
||||
"inventory.read",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"audit.read",
|
||||
]),
|
||||
operator: Object.freeze([
|
||||
"project.read",
|
||||
"inventory.read",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
"audit.read",
|
||||
]),
|
||||
engineer: Object.freeze([
|
||||
"project.read",
|
||||
"inventory.read",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"collection.manage",
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"command.plan",
|
||||
"audit.read",
|
||||
]),
|
||||
admin: Object.freeze([
|
||||
"project.read",
|
||||
"project.manage",
|
||||
"access.manage",
|
||||
"inventory.read",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"collection.manage",
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
"credential.manage",
|
||||
"audit.read",
|
||||
]),
|
||||
owner: DEVICE_PROJECT_CAPABILITIES,
|
||||
});
|
||||
|
||||
const hubRoleCeilings = Object.freeze({
|
||||
viewer: roleCapabilities.viewer,
|
||||
member: Object.freeze([
|
||||
...new Set([
|
||||
...roleCapabilities.viewer,
|
||||
...roleCapabilities.operator,
|
||||
...roleCapabilities.engineer,
|
||||
]),
|
||||
]),
|
||||
admin: roleCapabilities.admin,
|
||||
owner: DEVICE_PROJECT_CAPABILITIES,
|
||||
});
|
||||
|
||||
const projectRoleWeight = Object.freeze({
|
||||
viewer: 10,
|
||||
operator: 20,
|
||||
engineer: 30,
|
||||
admin: 40,
|
||||
owner: 50,
|
||||
});
|
||||
|
||||
export function normalizeManagementActor(input) {
|
||||
assertPlainObject(input, "device_management_actor_invalid");
|
||||
assertAllowedKeys(
|
||||
input,
|
||||
["userRef", "hubRole", "groupRefs", "ownerScopes"],
|
||||
"device_management_actor_field_unexpected",
|
||||
);
|
||||
|
||||
const userRef = normalizeOpaqueRef(input.userRef, "device_actor_user_ref_invalid");
|
||||
const hubRole = normalizeEnum(input.hubRole, hubRoleSet, "device_actor_hub_role_invalid");
|
||||
const groupRefs = normalizeOpaqueRefArray(
|
||||
input.groupRefs ?? [],
|
||||
"device_actor_group_refs_invalid",
|
||||
);
|
||||
const ownerScopes = normalizeOwnerScopeClaims(input.ownerScopes ?? []);
|
||||
|
||||
return Object.freeze({
|
||||
userRef,
|
||||
hubRole,
|
||||
groupRefs: Object.freeze(groupRefs),
|
||||
ownerScopes: Object.freeze(ownerScopes),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeManagementCommand(kind, input) {
|
||||
const normalizedKind = normalizeEnum(
|
||||
kind,
|
||||
commandKindSet,
|
||||
"device_management_command_kind_invalid",
|
||||
);
|
||||
assertPlainObject(input, "device_management_command_invalid");
|
||||
|
||||
if (normalizedKind === "owner_scope.ensure") {
|
||||
assertAllowedKeys(
|
||||
input,
|
||||
["scopeKind", "ownerRef", "displayName"],
|
||||
"device_management_command_field_unexpected",
|
||||
);
|
||||
return Object.freeze({
|
||||
scopeKind: normalizeScopeKind(input.scopeKind),
|
||||
ownerRef: normalizeOpaqueRef(input.ownerRef, "device_owner_ref_invalid"),
|
||||
displayName: normalizeDisplayText(input.displayName, 160, "device_owner_name_invalid"),
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedKind === "project.ensure") {
|
||||
assertAllowedKeys(
|
||||
input,
|
||||
["scopeKind", "ownerRef", "projectKey", "name", "description"],
|
||||
"device_management_command_field_unexpected",
|
||||
);
|
||||
return Object.freeze({
|
||||
scopeKind: normalizeScopeKind(input.scopeKind),
|
||||
ownerRef: normalizeOpaqueRef(input.ownerRef, "device_owner_ref_invalid"),
|
||||
projectKey: normalizeKey(input.projectKey, "device_project_key_invalid"),
|
||||
name: normalizeDisplayText(input.name, 160, "device_project_name_invalid"),
|
||||
description: normalizeOptionalText(
|
||||
input.description,
|
||||
2000,
|
||||
"device_project_description_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedKind === "collection.ensure") {
|
||||
assertAllowedKeys(
|
||||
input,
|
||||
["projectRef", "collectionKey", "name", "description"],
|
||||
"device_management_command_field_unexpected",
|
||||
);
|
||||
return Object.freeze({
|
||||
projectId: normalizeProjectRef(input.projectRef),
|
||||
collectionKey: normalizeKey(
|
||||
input.collectionKey,
|
||||
"device_collection_key_invalid",
|
||||
),
|
||||
name: normalizeDisplayText(input.name, 160, "device_collection_name_invalid"),
|
||||
description: normalizeOptionalText(
|
||||
input.description,
|
||||
2000,
|
||||
"device_collection_description_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(
|
||||
input,
|
||||
[
|
||||
"projectRef",
|
||||
"principalKind",
|
||||
"principalRef",
|
||||
"projectRole",
|
||||
"capabilityAllow",
|
||||
"capabilityDeny",
|
||||
"lifecycleState",
|
||||
],
|
||||
"device_management_command_field_unexpected",
|
||||
);
|
||||
const principalKind = normalizeEnum(
|
||||
input.principalKind,
|
||||
new Set(["user", "group"]),
|
||||
"device_project_principal_kind_invalid",
|
||||
);
|
||||
const projectRole = normalizeEnum(
|
||||
input.projectRole,
|
||||
projectRoleSet,
|
||||
"device_project_role_invalid",
|
||||
);
|
||||
if (projectRole === "owner" && principalKind !== "user") {
|
||||
throw domainError("device_project_owner_must_be_user", 400);
|
||||
}
|
||||
const capabilityAllow = normalizeCapabilities(input.capabilityAllow ?? []);
|
||||
const capabilityDeny = normalizeCapabilities(input.capabilityDeny ?? []);
|
||||
if (capabilityAllow.some((capability) => capabilityDeny.includes(capability))) {
|
||||
throw domainError("device_project_capability_overlap", 400);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
projectId: normalizeProjectRef(input.projectRef),
|
||||
principalKind,
|
||||
principalRef: normalizeOpaqueRef(
|
||||
input.principalRef,
|
||||
"device_project_principal_ref_invalid",
|
||||
),
|
||||
projectRole,
|
||||
capabilityAllow: Object.freeze(capabilityAllow),
|
||||
capabilityDeny: Object.freeze(capabilityDeny),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "active",
|
||||
new Set(["active", "revoked"]),
|
||||
"device_project_grant_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertActorCanManageOwnerScope(actorInput, scopeInput) {
|
||||
const actor = normalizeManagementActor(actorInput);
|
||||
const scope = {
|
||||
scopeKind: normalizeScopeKind(scopeInput?.scopeKind),
|
||||
ownerRef: normalizeOpaqueRef(scopeInput?.ownerRef, "device_owner_ref_invalid"),
|
||||
};
|
||||
|
||||
if (scope.scopeKind === "personal") {
|
||||
if (
|
||||
actor.userRef !== scope.ownerRef ||
|
||||
!["admin", "owner"].includes(actor.hubRole)
|
||||
) {
|
||||
throw domainError("device_owner_scope_access_denied", 403);
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
const hasClaim = actor.ownerScopes.some(
|
||||
(claim) => claim.scopeKind === "company" && claim.ownerRef === scope.ownerRef,
|
||||
);
|
||||
if (!hasClaim || !["admin", "owner"].includes(actor.hubRole)) {
|
||||
throw domainError("device_owner_scope_access_denied", 403);
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
export function resolveProjectAccess({ actor: actorInput, grants = [] }) {
|
||||
const actor = normalizeManagementActor(actorInput);
|
||||
if (!Array.isArray(grants)) {
|
||||
throw new TypeError("device_project_grants_invalid");
|
||||
}
|
||||
|
||||
const active = grants
|
||||
.map(normalizeStoredGrant)
|
||||
.filter((grant) => grant.lifecycleState === "active");
|
||||
const direct = active.find(
|
||||
(grant) => grant.principalKind === "user" && grant.principalRef === actor.userRef,
|
||||
);
|
||||
const matching = direct
|
||||
? [direct]
|
||||
: active
|
||||
.filter(
|
||||
(grant) =>
|
||||
grant.principalKind === "group" &&
|
||||
actor.groupRefs.includes(grant.principalRef),
|
||||
)
|
||||
.sort(compareGrantPriority);
|
||||
|
||||
if (matching.length === 0) {
|
||||
return Object.freeze({
|
||||
allowed: false,
|
||||
projectRole: null,
|
||||
capabilities: Object.freeze([]),
|
||||
sourceRefs: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
const primary = matching[0];
|
||||
const allowed = new Set();
|
||||
const denied = new Set();
|
||||
for (const grant of matching) {
|
||||
for (const capability of roleCapabilities[grant.projectRole]) {
|
||||
allowed.add(capability);
|
||||
}
|
||||
for (const capability of grant.capabilityAllow) allowed.add(capability);
|
||||
for (const capability of grant.capabilityDeny) denied.add(capability);
|
||||
}
|
||||
for (const capability of denied) allowed.delete(capability);
|
||||
|
||||
const hubCeiling = new Set(hubRoleCeilings[actor.hubRole]);
|
||||
const capabilities = [...allowed]
|
||||
.filter((capability) => hubCeiling.has(capability))
|
||||
.sort();
|
||||
|
||||
if (!capabilities.includes("project.read")) {
|
||||
return Object.freeze({
|
||||
allowed: false,
|
||||
projectRole: primary.projectRole,
|
||||
capabilities: Object.freeze([]),
|
||||
sourceRefs: Object.freeze(matching.map((grant) => grant.grantRef)),
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
allowed: true,
|
||||
projectRole: primary.projectRole,
|
||||
capabilities: Object.freeze(capabilities),
|
||||
sourceRefs: Object.freeze(matching.map((grant) => grant.grantRef)),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertProjectCapability(actor, grants, capability) {
|
||||
if (!capabilitySet.has(capability)) {
|
||||
throw new TypeError("device_project_capability_invalid");
|
||||
}
|
||||
const access = resolveProjectAccess({ actor, grants });
|
||||
if (!access.capabilities.includes(capability)) {
|
||||
throw domainError("device_project_capability_denied", 403);
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
export function assertGrantMutationAllowed(actor, grants, command, existingGrant = null) {
|
||||
const access = assertProjectCapability(actor, grants, "access.manage");
|
||||
if (
|
||||
command.projectRole === "owner" ||
|
||||
existingGrant?.projectRole === "owner"
|
||||
) {
|
||||
if (!access.capabilities.includes("device.transfer")) {
|
||||
throw domainError("device_project_owner_transfer_denied", 403);
|
||||
}
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
export function toProjectRef(projectId) {
|
||||
if (typeof projectId !== "string" || !projectRefPattern.test(`project:${projectId}`)) {
|
||||
throw new TypeError("device_project_id_invalid");
|
||||
}
|
||||
return `project:${projectId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function normalizeStoredGrant(input) {
|
||||
assertPlainObject(input, "device_project_grant_invalid");
|
||||
const grant = {
|
||||
grantRef: normalizeOpaqueRef(input.grantRef, "device_project_grant_ref_invalid"),
|
||||
principalKind: normalizeEnum(
|
||||
input.principalKind,
|
||||
new Set(["user", "group"]),
|
||||
"device_project_principal_kind_invalid",
|
||||
),
|
||||
principalRef: normalizeOpaqueRef(
|
||||
input.principalRef,
|
||||
"device_project_principal_ref_invalid",
|
||||
),
|
||||
projectRole: normalizeEnum(
|
||||
input.projectRole,
|
||||
projectRoleSet,
|
||||
"device_project_role_invalid",
|
||||
),
|
||||
capabilityAllow: normalizeCapabilities(input.capabilityAllow ?? []),
|
||||
capabilityDeny: normalizeCapabilities(input.capabilityDeny ?? []),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState,
|
||||
new Set(["active", "revoked"]),
|
||||
"device_project_grant_state_invalid",
|
||||
),
|
||||
};
|
||||
if (grant.projectRole === "owner" && grant.principalKind !== "user") {
|
||||
throw new TypeError("device_project_owner_must_be_user");
|
||||
}
|
||||
return grant;
|
||||
}
|
||||
|
||||
function compareGrantPriority(left, right) {
|
||||
return (
|
||||
projectRoleWeight[right.projectRole] - projectRoleWeight[left.projectRole] ||
|
||||
left.principalRef.localeCompare(right.principalRef)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOwnerScopeClaims(input) {
|
||||
if (!Array.isArray(input) || input.length > 128) {
|
||||
throw new TypeError("device_actor_owner_scopes_invalid");
|
||||
}
|
||||
const claims = input.map((claim) => {
|
||||
assertPlainObject(claim, "device_actor_owner_scope_invalid");
|
||||
assertAllowedKeys(
|
||||
claim,
|
||||
["scopeKind", "ownerRef"],
|
||||
"device_actor_owner_scope_field_unexpected",
|
||||
);
|
||||
return {
|
||||
scopeKind: normalizeScopeKind(claim.scopeKind),
|
||||
ownerRef: normalizeOpaqueRef(claim.ownerRef, "device_owner_ref_invalid"),
|
||||
};
|
||||
});
|
||||
const byKey = new Map(
|
||||
claims.map((claim) => [`${claim.scopeKind}\0${claim.ownerRef}`, claim]),
|
||||
);
|
||||
return [...byKey.values()].sort((left, right) =>
|
||||
`${left.scopeKind}:${left.ownerRef}`.localeCompare(
|
||||
`${right.scopeKind}:${right.ownerRef}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCapabilities(input) {
|
||||
if (!Array.isArray(input) || input.length > DEVICE_PROJECT_CAPABILITIES.length) {
|
||||
throw new TypeError("device_project_capabilities_invalid");
|
||||
}
|
||||
const normalized = input.map((capability) =>
|
||||
normalizeEnum(
|
||||
capability,
|
||||
capabilitySet,
|
||||
"device_project_capability_invalid",
|
||||
),
|
||||
);
|
||||
return [...new Set(normalized)].sort();
|
||||
}
|
||||
|
||||
function normalizeOpaqueRefArray(input, code) {
|
||||
if (!Array.isArray(input) || input.length > 128) throw new TypeError(code);
|
||||
return [...new Set(input.map((value) => normalizeOpaqueRef(value, code)))].sort();
|
||||
}
|
||||
|
||||
function normalizeProjectRef(value) {
|
||||
if (typeof value !== "string") throw new TypeError("device_project_ref_invalid");
|
||||
const match = value.match(projectRefPattern);
|
||||
if (!match) throw new TypeError("device_project_ref_invalid");
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeScopeKind(value) {
|
||||
return normalizeEnum(
|
||||
value,
|
||||
new Set(["company", "personal"]),
|
||||
"device_owner_scope_kind_invalid",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeKey(value, code) {
|
||||
if (typeof value !== "string" || !keyPattern.test(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOpaqueRef(value, code) {
|
||||
if (typeof value !== "string" || !opaqueRefPattern.test(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDisplayText(value, maxLength, code) {
|
||||
if (typeof value !== "string") throw new TypeError(code);
|
||||
const normalized = value.trim();
|
||||
if (normalized.length < 1 || normalized.length > maxLength) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
if (/\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value, maxLength, code) {
|
||||
if (value == null || value === "") return null;
|
||||
return normalizeDisplayText(value, maxLength, code);
|
||||
}
|
||||
|
||||
function normalizeEnum(value, allowed, code) {
|
||||
if (typeof value !== "string" || !allowed.has(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertAllowedKeys(input, allowed, code) {
|
||||
const allowedSet = new Set(allowed);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedSet.has(key)) throw new TypeError(`${code}:${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlainObject(value, code) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
Reference in New Issue
Block a user