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; }