feat(device-core): add restricted identity references
This commit is contained in:
@@ -116,6 +116,84 @@ test("management API forwards claim as evidence references without identity inpu
|
||||
}
|
||||
});
|
||||
|
||||
test("management API accepts only a canonical credential reference", async () => {
|
||||
let executed;
|
||||
const runtime = await startServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async (input) => {
|
||||
executed = input;
|
||||
return {
|
||||
replayed: false,
|
||||
result: {
|
||||
credentialBinding: {
|
||||
credentialBindingRef:
|
||||
"credential-binding:44444444-4444-4444-8444-444444444444",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/device-credential-bindings:upsert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders(),
|
||||
body: JSON.stringify({
|
||||
projectRef: "project:11111111-1111-4111-8111-111111111111",
|
||||
deviceRef: "device:22222222-2222-4222-8222-222222222222",
|
||||
purpose: "tracker.command",
|
||||
credentialRef: {
|
||||
owner: "ndc_l2_credentials",
|
||||
reference: "ndc-credref:pilot-command-0001",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
executed.commandKind,
|
||||
"device_credential_binding.upsert",
|
||||
);
|
||||
assert.deepEqual(executed.command.credentialRef, {
|
||||
owner: "ndc_l2_credentials",
|
||||
reference: "ndc-credref:pilot-command-0001",
|
||||
});
|
||||
|
||||
const rejected = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/device-credential-bindings:upsert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...managementHeaders(),
|
||||
"Idempotency-Key": "phase24-credential-invalid-0001",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectRef: "project:11111111-1111-4111-8111-111111111111",
|
||||
deviceRef: "device:22222222-2222-4222-8222-222222222222",
|
||||
purpose: "tracker.command",
|
||||
credentialRef: {
|
||||
owner: "device_core",
|
||||
reference: "ndc-credref:pilot-command-0001",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(rejected.status, 400);
|
||||
assert.equal(
|
||||
(await rejected.json()).error,
|
||||
"ndc_credential_reference_owner_invalid",
|
||||
);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function startServer(options) {
|
||||
const server = createControlCoreApp(options);
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -45,6 +45,7 @@ test("claims only matching observed enrollment evidence into direct ownership",
|
||||
display_name: command.displayName,
|
||||
})],
|
||||
}),
|
||||
step("insert into device_restricted_identifiers"),
|
||||
step("update device_discoveries", { rows: [{ id: discoveryId }] }),
|
||||
step("update device_enrollment_intents", { rows: [{ id: enrollmentId }] }),
|
||||
step("insert into device_ownership_transitions"),
|
||||
@@ -109,6 +110,7 @@ test("authorized transfer preserves history and detaches source collections", as
|
||||
projectStep(targetProjectId, targetOwnerId),
|
||||
grantsStep(actor, "owner"),
|
||||
step("from device_sessions", { rows: [{ active: false }] }),
|
||||
step("from device_credential_bindings", { rows: [{ active: false }] }),
|
||||
step("delete from device_collection_members", { rows: [], rowCount: 2 }),
|
||||
step("update device_instances", {
|
||||
rows: [deviceRow({
|
||||
@@ -117,6 +119,7 @@ test("authorized transfer preserves history and detaches source collections", as
|
||||
device_key: command.targetDeviceKey,
|
||||
})],
|
||||
}),
|
||||
step("update device_restricted_identifiers", { rows: [], rowCount: 1 }),
|
||||
step("insert into device_ownership_transitions"),
|
||||
step("insert into device_audit_events"),
|
||||
step("insert into device_audit_events"),
|
||||
@@ -135,6 +138,37 @@ test("authorized transfer preserves history and detaches source collections", as
|
||||
assert.equal(result.result.transferred, true);
|
||||
assert.equal(result.result.device.projectRef, `project:${targetProjectId}`);
|
||||
assert.equal(result.result.detachedCollectionCount, 2);
|
||||
assert.equal(result.result.transferredIdentifierCount, 1);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("transfer fails closed while a credential binding is active", async () => {
|
||||
const actor = managementActor("owner");
|
||||
const command = transferCommand();
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-transfer-credential-bound"),
|
||||
step("from device_instances", { rows: [deviceRow()] }),
|
||||
projectStep(sourceProjectId, sourceOwnerId),
|
||||
grantsStep(actor, "owner"),
|
||||
projectStep(targetProjectId, targetOwnerId),
|
||||
grantsStep(actor, "owner"),
|
||||
step("from device_sessions", { rows: [{ active: false }] }),
|
||||
step("from device_credential_bindings", { rows: [{ active: true }] }),
|
||||
step("rollback"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
await assert.rejects(
|
||||
repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "device.transfer",
|
||||
command,
|
||||
digestCharacter: "f",
|
||||
})),
|
||||
/device_transfer_active_credential_binding/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||
normalizeSensitiveReferenceManagementCommand,
|
||||
} from "../src/sensitive-reference-management.mjs";
|
||||
import {
|
||||
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
normalizeDeviceManagementCommand,
|
||||
} from "../src/management-command.mjs";
|
||||
|
||||
const projectRef = "project:11111111-1111-4111-8111-111111111111";
|
||||
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
|
||||
|
||||
test("credential binding commands share the strict management surface", () => {
|
||||
for (const kind of DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS) {
|
||||
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeDeviceManagementCommand(
|
||||
"device_credential_binding.upsert",
|
||||
upsertInput(),
|
||||
).projectId,
|
||||
projectRef.slice("project:".length),
|
||||
);
|
||||
});
|
||||
|
||||
test("credential binding accepts only the platform canonical opaque ref", () => {
|
||||
const command = normalizeSensitiveReferenceManagementCommand(
|
||||
"device_credential_binding.upsert",
|
||||
upsertInput(),
|
||||
);
|
||||
|
||||
assert.deepEqual(command.credentialRef, {
|
||||
owner: "ndc_l2_credentials",
|
||||
reference: "ndc-credref:pilot-command-0001",
|
||||
});
|
||||
assert.equal(Object.isFrozen(command.credentialRef), true);
|
||||
assert.throws(
|
||||
() => normalizeSensitiveReferenceManagementCommand(
|
||||
"device_credential_binding.upsert",
|
||||
{
|
||||
...upsertInput(),
|
||||
credentialRef: {
|
||||
owner: "device_core",
|
||||
reference: "ndc-credref:pilot-command-0001",
|
||||
},
|
||||
},
|
||||
),
|
||||
/ndc_credential_reference_owner_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeSensitiveReferenceManagementCommand(
|
||||
"device_credential_binding.upsert",
|
||||
{
|
||||
...upsertInput(),
|
||||
credentialRef: {
|
||||
owner: "ndc_l2_credentials",
|
||||
reference: "Bearer plaintext-is-forbidden",
|
||||
},
|
||||
},
|
||||
),
|
||||
/ndc_credential_reference_value_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("credential binding rejects raw secret-shaped fields", () => {
|
||||
for (const field of ["password", "token", "secretValue", "endpoint"]) {
|
||||
assert.throws(
|
||||
() => normalizeSensitiveReferenceManagementCommand(
|
||||
"device_credential_binding.upsert",
|
||||
{ ...upsertInput(), [field]: "forbidden" },
|
||||
),
|
||||
new RegExp(`device_management_command_field_unexpected:${field}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("credential revoke has no credential reference input", () => {
|
||||
const command = normalizeSensitiveReferenceManagementCommand(
|
||||
"device_credential_binding.revoke",
|
||||
{
|
||||
projectRef,
|
||||
deviceRef,
|
||||
purpose: "tracker.command",
|
||||
resolutionCode: "operator.rotation",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(command.resolutionCode, "operator.rotation");
|
||||
assert.equal("credentialRef" in command, false);
|
||||
});
|
||||
|
||||
function upsertInput() {
|
||||
return {
|
||||
projectRef,
|
||||
deviceRef,
|
||||
purpose: "tracker.command",
|
||||
credentialRef: {
|
||||
owner: "ndc_l2_credentials",
|
||||
reference: "ndc-credref:pilot-command-0001",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const schemaUrl = new URL(
|
||||
"../migrations/008_device_sensitive_references.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const commandsUrl = new URL(
|
||||
"../migrations/009_device_sensitive_reference_commands.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("sensitive reference schema stores only digest, mask and canonical refs", async () => {
|
||||
const sql = await readFile(schemaUrl, "utf8");
|
||||
|
||||
assert.match(sql, /create table if not exists device_restricted_identifiers/);
|
||||
assert.match(sql, /identifier_digest text not null/);
|
||||
assert.match(sql, /identifier_masked text not null/);
|
||||
assert.match(sql, /device_restricted_identifiers_active_identity_idx/);
|
||||
assert.match(sql, /device_restricted_identifiers_primary_idx/);
|
||||
assert.match(sql, /device_identifier_ownership_mismatch/);
|
||||
assert.match(sql, /device_active_identifier_ownership_mismatch/);
|
||||
assert.match(sql, /deferrable initially deferred/);
|
||||
assert.match(sql, /create table if not exists device_credential_bindings/);
|
||||
assert.match(sql, /credential_owner = 'ndc_l2_credentials'/);
|
||||
assert.match(sql, /\^ndc-credref:/);
|
||||
assert.match(sql, /device_credential_binding_ownership_mismatch/);
|
||||
assert.match(sql, /device_transfer_active_credential_binding/);
|
||||
assert.match(sql, /owner_scope_id is null or credential_ref is null/);
|
||||
assert.doesNotMatch(sql, /imei\s+text|serial\s+text|password\s+text|token\s+text/i);
|
||||
assert.doesNotMatch(sql, /insert\s+into/i);
|
||||
});
|
||||
|
||||
test("credential commands extend durable receipts after their schema", async () => {
|
||||
const commands = await readFile(commandsUrl, "utf8");
|
||||
const repository = await readFile(repositoryUrl, "utf8");
|
||||
|
||||
assert.match(commands, /'device_credential_binding\.upsert'/);
|
||||
assert.match(commands, /'device_credential_binding\.revoke'/);
|
||||
const schemaIndex = repository.indexOf("008_device_sensitive_references.sql");
|
||||
const commandsIndex = repository.indexOf(
|
||||
"009_device_sensitive_reference_commands.sql",
|
||||
);
|
||||
assert.notEqual(schemaIndex, -1);
|
||||
assert.notEqual(commandsIndex, -1);
|
||||
assert.ok(schemaIndex < commandsIndex);
|
||||
});
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { normalizeDeviceManagementCommand } from "../src/management-command.mjs";
|
||||
import { PostgresDeviceRepository } from "../src/postgres-repository.mjs";
|
||||
import { normalizeManagementActor } from "../src/project-management.mjs";
|
||||
|
||||
const now = new Date("2026-08-10T00:00:00.000Z");
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
const ownerId = "22222222-2222-4222-8222-222222222222";
|
||||
const deviceId = "33333333-3333-4333-8333-333333333333";
|
||||
const bindingId = "44444444-4444-4444-8444-444444444444";
|
||||
const canonicalRef = "ndc-credref:pilot-command-0001";
|
||||
|
||||
test("creates a canonical binding without returning or auditing its reference", async () => {
|
||||
const actor = managementActor();
|
||||
const command = upsertCommand();
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-credential-upsert"),
|
||||
projectStep(),
|
||||
grantsStep(actor),
|
||||
step("from device_instances", { rows: [deviceRow()] }),
|
||||
step("from device_credential_bindings", { rows: [] }),
|
||||
step("insert into device_credential_bindings", {
|
||||
rows: [bindingRow()],
|
||||
}),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "device_credential_binding.upsert",
|
||||
command,
|
||||
digestCharacter: "a",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.created, true);
|
||||
assert.equal(result.result.rotated, false);
|
||||
assert.equal(
|
||||
result.result.credentialBinding.credentialBindingRef,
|
||||
`credential-binding:${bindingId}`,
|
||||
);
|
||||
assert.equal(JSON.stringify(result.result).includes(canonicalRef), false);
|
||||
const auditCall = client.calls.find((call) =>
|
||||
String(call.sql).includes("insert into device_audit_events")
|
||||
);
|
||||
assert.ok(auditCall);
|
||||
assert.equal(JSON.stringify(auditCall.params).includes(canonicalRef), false);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("revokes by device and purpose without accepting a credential ref", async () => {
|
||||
const actor = managementActor();
|
||||
const command = normalizeDeviceManagementCommand(
|
||||
"device_credential_binding.revoke",
|
||||
{
|
||||
projectRef: `project:${projectId}`,
|
||||
deviceRef: `device:${deviceId}`,
|
||||
purpose: "tracker.command",
|
||||
resolutionCode: "operator.rotation",
|
||||
},
|
||||
);
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-credential-revoke"),
|
||||
projectStep(),
|
||||
grantsStep(actor),
|
||||
step("from device_instances", { rows: [deviceRow()] }),
|
||||
step("update device_credential_bindings", {
|
||||
rows: [bindingRow({ lifecycle_state: "revoked" })],
|
||||
}),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "device_credential_binding.revoke",
|
||||
command,
|
||||
digestCharacter: "b",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.revoked, true);
|
||||
assert.equal(result.result.credentialBinding.lifecycleState, "revoked");
|
||||
assert.equal("credentialRef" in command, false);
|
||||
assert.equal(JSON.stringify(result.result).includes(canonicalRef), false);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("rotates an active binding atomically and keeps both refs out of audit", async () => {
|
||||
const actor = managementActor();
|
||||
const command = upsertCommand();
|
||||
const oldRef = "ndc-credref:pilot-command-old-0001";
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-credential-rotate"),
|
||||
projectStep(),
|
||||
grantsStep(actor),
|
||||
step("from device_instances", { rows: [deviceRow()] }),
|
||||
step("from device_credential_bindings", {
|
||||
rows: [bindingRow({ credential_ref: oldRef })],
|
||||
}),
|
||||
step("update device_credential_bindings"),
|
||||
step("insert into device_credential_bindings", {
|
||||
rows: [bindingRow({
|
||||
id: "66666666-6666-4666-8666-666666666666",
|
||||
})],
|
||||
}),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "device_credential_binding.upsert",
|
||||
command,
|
||||
digestCharacter: "c",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.created, true);
|
||||
assert.equal(result.result.rotated, true);
|
||||
const auditCall = client.calls.find((call) =>
|
||||
String(call.sql).includes("insert into device_audit_events")
|
||||
);
|
||||
assert.ok(auditCall);
|
||||
assert.equal(JSON.stringify(auditCall.params).includes(oldRef), false);
|
||||
assert.equal(JSON.stringify(auditCall.params).includes(canonicalRef), false);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
function upsertCommand() {
|
||||
return normalizeDeviceManagementCommand(
|
||||
"device_credential_binding.upsert",
|
||||
{
|
||||
projectRef: `project:${projectId}`,
|
||||
deviceRef: `device:${deviceId}`,
|
||||
purpose: "tracker.command",
|
||||
credentialRef: {
|
||||
owner: "ndc_l2_credentials",
|
||||
reference: canonicalRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function managementActor() {
|
||||
return normalizeManagementActor({
|
||||
userRef: "user:credential-operator",
|
||||
hubRole: "admin",
|
||||
groupRefs: [],
|
||||
ownerScopes: [],
|
||||
});
|
||||
}
|
||||
|
||||
function projectStep() {
|
||||
return step("from device_projects p", {
|
||||
rows: [{
|
||||
id: projectId,
|
||||
owner_scope_id: ownerId,
|
||||
lifecycle_state: "active",
|
||||
scope_kind: "company",
|
||||
owner_ref: "client:example-company",
|
||||
owner_display_name: "Example Company",
|
||||
owner_lifecycle_state: "active",
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function grantsStep(actor) {
|
||||
return step("from device_project_grants", {
|
||||
rows: [{
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
principal_kind: "user",
|
||||
principal_ref: actor.userRef,
|
||||
project_role: "admin",
|
||||
capability_allow: [],
|
||||
capability_deny: [],
|
||||
lifecycle_state: "active",
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function deviceRow() {
|
||||
return {
|
||||
id: deviceId,
|
||||
owner_scope_id: ownerId,
|
||||
project_id: projectId,
|
||||
lifecycle_state: "claimed",
|
||||
};
|
||||
}
|
||||
|
||||
function bindingRow(overrides = {}) {
|
||||
return {
|
||||
id: bindingId,
|
||||
device_id: deviceId,
|
||||
owner_scope_id: ownerId,
|
||||
project_id: projectId,
|
||||
purpose: "tracker.command",
|
||||
credential_owner: "ndc_l2_credentials",
|
||||
lifecycle_state: "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function receiptStep(id) {
|
||||
return step("insert into device_management_command_receipts", {
|
||||
rows: [{ id }],
|
||||
});
|
||||
}
|
||||
|
||||
function commandInput({ actor, commandKind, command, digestCharacter }) {
|
||||
return {
|
||||
idempotencyKey: `phase24-${commandKind.replaceAll(".", "-")}-0001`,
|
||||
commandKind,
|
||||
requestDigest: `sha256:${digestCharacter.repeat(64)}`,
|
||||
actor,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
function repositoryWithClient(client) {
|
||||
return new PostgresDeviceRepository({
|
||||
pool: {
|
||||
query: async () => ({ rows: [] }),
|
||||
connect: async () => client,
|
||||
end: async () => undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function step(includes, result = { rows: [] }) {
|
||||
return { includes, result };
|
||||
}
|
||||
|
||||
function scriptedClient(steps) {
|
||||
const queue = [...steps];
|
||||
return {
|
||||
calls: [],
|
||||
released: false,
|
||||
async query(sql, params = []) {
|
||||
this.calls.push({ sql, params });
|
||||
const next = queue.shift();
|
||||
assert.ok(next, `Unexpected query: ${sql}`);
|
||||
assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i"));
|
||||
return next.result;
|
||||
},
|
||||
release() {
|
||||
this.released = true;
|
||||
},
|
||||
remaining() {
|
||||
return queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user