feat(device-core): add device ownership lifecycle
This commit is contained in:
@@ -48,6 +48,9 @@ export function normalizeDiscoverySignal(input) {
|
||||
}
|
||||
|
||||
const sessionRef = normalizeOpaqueRef(input.sessionRef, "session_ref");
|
||||
const routeRef = input.routeRef == null
|
||||
? undefined
|
||||
: normalizeEntityRef(input.routeRef, "route", "route_ref");
|
||||
const modelProfileRef = normalizeOpaqueRef(
|
||||
input.modelProfileRef,
|
||||
"model_profile_ref",
|
||||
@@ -60,6 +63,7 @@ export function normalizeDiscoverySignal(input) {
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef,
|
||||
...(routeRef ? { routeRef } : {}),
|
||||
modelProfileRef,
|
||||
protocol,
|
||||
observedAt,
|
||||
@@ -79,6 +83,7 @@ export function toSafeDiscoveryView(signal, options = {}) {
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_DISCOVERY_VIEW_SCHEMA,
|
||||
...(discoveryRef ? { discoveryRef } : {}),
|
||||
...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}),
|
||||
modelProfileRef: normalized.modelProfileRef,
|
||||
protocol: normalized.protocol,
|
||||
observedAt: normalized.observedAt,
|
||||
@@ -216,6 +221,19 @@ function normalizeOpaqueRef(value, label) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, prefix, label) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !new RegExp(
|
||||
`^${prefix}:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
|
||||
"i",
|
||||
).test(value)
|
||||
) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeUpperToken(value, label) {
|
||||
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
|
||||
@@ -48,6 +48,19 @@ test("safe discovery projection masks the restricted identifier", () => {
|
||||
assertSafeProjection(view);
|
||||
});
|
||||
|
||||
test("route-bound discovery preserves only a validated opaque route reference", () => {
|
||||
const routeRef = "route:11111111-1111-4111-8111-111111111111";
|
||||
const signal = normalizeDiscoverySignal({ ...fakeSignal, routeRef });
|
||||
const view = toSafeDiscoveryView(signal);
|
||||
|
||||
assert.equal(signal.routeRef, routeRef);
|
||||
assert.equal(view.routeRef, routeRef);
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({ ...fakeSignal, routeRef: "route:generic" }),
|
||||
/route_ref_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("identifier hashing requires a strong process-only pepper", () => {
|
||||
const identifier = { kind: "imei", value: fakeImei };
|
||||
assert.throws(
|
||||
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
begin;
|
||||
|
||||
create unique index if not exists device_projects_id_owner_scope_idx
|
||||
on device_projects (id, owner_scope_id);
|
||||
|
||||
alter table device_instances
|
||||
alter column contour_id drop not null,
|
||||
add column if not exists owner_scope_id uuid references device_owner_scopes(id),
|
||||
add column if not exists device_key text
|
||||
check (
|
||||
device_key is null
|
||||
or device_key ~ '^[a-z][a-z0-9-]{1,62}$'
|
||||
);
|
||||
|
||||
create unique index if not exists device_instances_project_key_idx
|
||||
on device_instances (project_id, device_key)
|
||||
where device_key is not null;
|
||||
|
||||
create unique index if not exists device_instances_id_project_owner_idx
|
||||
on device_instances (id, project_id, owner_scope_id);
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_instances_project_owner_fk'
|
||||
and conrelid = 'device_instances'::regclass
|
||||
) then
|
||||
alter table device_instances
|
||||
add constraint device_instances_project_owner_fk
|
||||
foreign key (project_id, owner_scope_id)
|
||||
references device_projects(id, owner_scope_id)
|
||||
not valid;
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_instances_ownership_mode_check'
|
||||
and conrelid = 'device_instances'::regclass
|
||||
) then
|
||||
alter table device_instances
|
||||
add constraint device_instances_ownership_mode_check
|
||||
check (
|
||||
(
|
||||
owner_scope_id is not null
|
||||
and project_id is not null
|
||||
)
|
||||
or
|
||||
(
|
||||
owner_scope_id is null
|
||||
and project_id is null
|
||||
and contour_id is not null
|
||||
)
|
||||
) not valid;
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
alter table device_discoveries
|
||||
add column if not exists session_ref text
|
||||
check (
|
||||
session_ref is null
|
||||
or length(btrim(session_ref)) between 3 and 256
|
||||
),
|
||||
add column if not exists project_id uuid references device_projects(id),
|
||||
add column if not exists route_id uuid,
|
||||
add column if not exists enrollment_intent_id uuid,
|
||||
add column if not exists resolution_code text
|
||||
check (
|
||||
resolution_code is null
|
||||
or resolution_code ~ '^[a-z][a-z0-9._-]{1,63}$'
|
||||
),
|
||||
add column if not exists resolved_at timestamptz,
|
||||
add column if not exists resolved_by_ref text
|
||||
check (
|
||||
resolved_by_ref is null
|
||||
or length(btrim(resolved_by_ref)) between 3 and 256
|
||||
);
|
||||
|
||||
create unique index if not exists device_discoveries_id_project_idx
|
||||
on device_discoveries (id, project_id);
|
||||
|
||||
create index if not exists device_discoveries_route_state_seen_idx
|
||||
on device_discoveries (route_id, lifecycle_state, last_observed_at desc)
|
||||
where route_id is not null;
|
||||
|
||||
create unique index if not exists device_enrollment_intents_context_idx
|
||||
on device_enrollment_intents (
|
||||
id,
|
||||
project_id,
|
||||
route_id,
|
||||
model_profile_ref
|
||||
);
|
||||
|
||||
create unique index if not exists device_enrollment_intents_active_identity_idx
|
||||
on device_enrollment_intents (
|
||||
expected_identifier_kind,
|
||||
expected_identifier_digest,
|
||||
model_profile_ref
|
||||
)
|
||||
where lifecycle_state in ('pending', 'observed', 'claimed');
|
||||
|
||||
alter table device_enrollment_intents
|
||||
add column if not exists observed_discovery_id uuid,
|
||||
add column if not exists observed_at timestamptz,
|
||||
add column if not exists claimed_at timestamptz,
|
||||
add column if not exists resolution_code text
|
||||
check (
|
||||
resolution_code is null
|
||||
or resolution_code ~ '^[a-z][a-z0-9._-]{1,63}$'
|
||||
),
|
||||
add column if not exists resolved_at timestamptz,
|
||||
add column if not exists resolved_by_ref text
|
||||
check (
|
||||
resolved_by_ref is null
|
||||
or length(btrim(resolved_by_ref)) between 3 and 256
|
||||
);
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_discoveries_route_context_fk'
|
||||
and conrelid = 'device_discoveries'::regclass
|
||||
) then
|
||||
alter table device_discoveries
|
||||
add constraint device_discoveries_route_context_fk
|
||||
foreign key (route_id, project_id, model_profile_ref)
|
||||
references device_routes(id, project_id, model_profile_ref)
|
||||
not valid;
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_discoveries_enrollment_context_fk'
|
||||
and conrelid = 'device_discoveries'::regclass
|
||||
) then
|
||||
alter table device_discoveries
|
||||
add constraint device_discoveries_enrollment_context_fk
|
||||
foreign key (
|
||||
enrollment_intent_id,
|
||||
project_id,
|
||||
route_id,
|
||||
model_profile_ref
|
||||
) references device_enrollment_intents (
|
||||
id,
|
||||
project_id,
|
||||
route_id,
|
||||
model_profile_ref
|
||||
) not valid;
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_discoveries_route_context_check'
|
||||
and conrelid = 'device_discoveries'::regclass
|
||||
) then
|
||||
alter table device_discoveries
|
||||
add constraint device_discoveries_route_context_check
|
||||
check (
|
||||
(project_id is null and route_id is null)
|
||||
or
|
||||
(project_id is not null and route_id is not null)
|
||||
) not valid;
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_discoveries_enrollment_context_check'
|
||||
and conrelid = 'device_discoveries'::regclass
|
||||
) then
|
||||
alter table device_discoveries
|
||||
add constraint device_discoveries_enrollment_context_check
|
||||
check (
|
||||
enrollment_intent_id is null
|
||||
or (project_id is not null and route_id is not null)
|
||||
) not valid;
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_enrollment_observed_discovery_fk'
|
||||
and conrelid = 'device_enrollment_intents'::regclass
|
||||
) then
|
||||
alter table device_enrollment_intents
|
||||
add constraint device_enrollment_observed_discovery_fk
|
||||
foreign key (observed_discovery_id, project_id)
|
||||
references device_discoveries(id, project_id)
|
||||
not valid;
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_enrollment_lifecycle_evidence_check'
|
||||
and conrelid = 'device_enrollment_intents'::regclass
|
||||
) then
|
||||
alter table device_enrollment_intents
|
||||
add constraint device_enrollment_lifecycle_evidence_check
|
||||
check (
|
||||
lifecycle_state not in ('observed', 'claimed')
|
||||
or (observed_discovery_id is not null and observed_at is not null)
|
||||
) not valid;
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
alter table device_sessions
|
||||
drop constraint if exists device_sessions_device_id_project_id_fkey;
|
||||
|
||||
alter table device_enrollment_intents
|
||||
drop constraint if exists device_enrollment_intents_claimed_device_id_project_id_fkey;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_sessions_device_id_fk'
|
||||
and conrelid = 'device_sessions'::regclass
|
||||
) then
|
||||
alter table device_sessions
|
||||
add constraint device_sessions_device_id_fk
|
||||
foreign key (device_id) references device_instances(id)
|
||||
not valid;
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'device_enrollment_claimed_device_id_fk'
|
||||
and conrelid = 'device_enrollment_intents'::regclass
|
||||
) then
|
||||
alter table device_enrollment_intents
|
||||
add constraint device_enrollment_claimed_device_id_fk
|
||||
foreign key (claimed_device_id) references device_instances(id)
|
||||
not valid;
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
create or replace function device_assert_session_current_project()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if new.device_id is not null and not exists (
|
||||
select 1 from device_instances di
|
||||
where di.id = new.device_id
|
||||
and di.project_id = new.project_id
|
||||
) then
|
||||
raise foreign_key_violation using
|
||||
message = 'device_session_project_mismatch';
|
||||
end if;
|
||||
return new;
|
||||
end
|
||||
$$;
|
||||
|
||||
drop trigger if exists device_sessions_current_project_guard
|
||||
on device_sessions;
|
||||
|
||||
create trigger device_sessions_current_project_guard
|
||||
before insert or update of device_id, project_id
|
||||
on device_sessions
|
||||
for each row
|
||||
execute function device_assert_session_current_project();
|
||||
|
||||
create or replace function device_assert_enrollment_current_project()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if new.claimed_device_id is not null and not exists (
|
||||
select 1 from device_instances di
|
||||
where di.id = new.claimed_device_id
|
||||
and di.project_id = new.project_id
|
||||
) then
|
||||
raise foreign_key_violation using
|
||||
message = 'device_enrollment_project_mismatch';
|
||||
end if;
|
||||
return new;
|
||||
end
|
||||
$$;
|
||||
|
||||
drop trigger if exists device_enrollment_current_project_guard
|
||||
on device_enrollment_intents;
|
||||
|
||||
create trigger device_enrollment_current_project_guard
|
||||
before insert or update of claimed_device_id, project_id
|
||||
on device_enrollment_intents
|
||||
for each row
|
||||
execute function device_assert_enrollment_current_project();
|
||||
|
||||
create table if not exists device_ownership_transitions (
|
||||
id uuid primary key,
|
||||
device_id uuid not null references device_instances(id),
|
||||
transition_kind text not null
|
||||
check (transition_kind in ('claim', 'transfer')),
|
||||
source_owner_scope_id uuid,
|
||||
source_project_id uuid,
|
||||
target_owner_scope_id uuid not null,
|
||||
target_project_id uuid not null,
|
||||
actor_ref text not null
|
||||
check (length(btrim(actor_ref)) between 3 and 256),
|
||||
occurred_at timestamptz not null default now(),
|
||||
foreign key (source_project_id, source_owner_scope_id)
|
||||
references device_projects(id, owner_scope_id),
|
||||
foreign key (target_project_id, target_owner_scope_id)
|
||||
references device_projects(id, owner_scope_id),
|
||||
check (
|
||||
(
|
||||
transition_kind = 'claim'
|
||||
and source_owner_scope_id is null
|
||||
and source_project_id is null
|
||||
)
|
||||
or
|
||||
(
|
||||
transition_kind = 'transfer'
|
||||
and source_owner_scope_id is not null
|
||||
and source_project_id is not null
|
||||
and (
|
||||
source_owner_scope_id <> target_owner_scope_id
|
||||
or source_project_id <> target_project_id
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create unique index if not exists device_ownership_single_claim_idx
|
||||
on device_ownership_transitions (device_id)
|
||||
where transition_kind = 'claim';
|
||||
|
||||
create index if not exists device_ownership_device_time_idx
|
||||
on device_ownership_transitions (device_id, occurred_at desc);
|
||||
|
||||
commit;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
begin;
|
||||
|
||||
alter table device_management_command_receipts
|
||||
drop constraint if exists device_management_command_receipts_command_kind_check;
|
||||
|
||||
alter table device_management_command_receipts
|
||||
add constraint device_management_command_receipts_command_kind_check
|
||||
check (command_kind in (
|
||||
'owner_scope.ensure',
|
||||
'project.ensure',
|
||||
'collection.ensure',
|
||||
'project_grant.upsert',
|
||||
'adapter_package.ensure',
|
||||
'adapter_version.register',
|
||||
'model_profile.register',
|
||||
'edge.ensure',
|
||||
'route.ensure',
|
||||
'enrollment_intent.ensure',
|
||||
'device.claim',
|
||||
'device.transfer',
|
||||
'discovery.reject',
|
||||
'discovery.expire'
|
||||
));
|
||||
|
||||
commit;
|
||||
@@ -23,6 +23,10 @@ const managementRoutes = new Map([
|
||||
["/internal/v1/management/edges:ensure", "edge.ensure"],
|
||||
["/internal/v1/management/routes:ensure", "route.ensure"],
|
||||
["/internal/v1/management/enrollment-intents:ensure", "enrollment_intent.ensure"],
|
||||
["/internal/v1/management/devices:claim", "device.claim"],
|
||||
["/internal/v1/management/devices:transfer", "device.transfer"],
|
||||
["/internal/v1/management/discoveries:reject", "discovery.reject"],
|
||||
["/internal/v1/management/discoveries:expire", "discovery.expire"],
|
||||
]);
|
||||
|
||||
export function createControlCoreApp({
|
||||
@@ -154,6 +158,8 @@ export function createControlCoreApp({
|
||||
const discovery = await repository.upsertQuarantineDiscovery({
|
||||
identifierDigest,
|
||||
safeView,
|
||||
sessionRef: signal.sessionRef,
|
||||
routeRef: signal.routeRef ?? null,
|
||||
});
|
||||
return writeJson(response, discovery.created ? 201 : 200, {
|
||||
ok: true,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export async function observeQuarantineDiscovery({
|
||||
pool,
|
||||
identifierDigest,
|
||||
safeView,
|
||||
sessionRef,
|
||||
routeRef = null,
|
||||
}) {
|
||||
if ((safeView.routeRef ?? null) !== routeRef) {
|
||||
throw new TypeError("device_discovery_route_ref_mismatch");
|
||||
}
|
||||
const routeId = routeRef == null ? null : parseEntityRef(routeRef, "route");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
const route = routeId == null
|
||||
? null
|
||||
: await findActiveRoute(client, routeId, safeView);
|
||||
const enrollment = route == null
|
||||
? null
|
||||
: await findMatchingEnrollment(client, {
|
||||
route,
|
||||
identifierDigest,
|
||||
safeView,
|
||||
});
|
||||
const result = await client.query(
|
||||
`insert into device_discoveries (
|
||||
id,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
model_profile_ref,
|
||||
protocol,
|
||||
lifecycle_state,
|
||||
first_observed_at,
|
||||
last_observed_at,
|
||||
evidence,
|
||||
session_ref,
|
||||
project_id,
|
||||
route_id,
|
||||
enrollment_intent_id
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb,
|
||||
$9, $10, $11, $12
|
||||
)
|
||||
on conflict (identifier_kind, identifier_digest, model_profile_ref)
|
||||
do update set
|
||||
last_observed_at = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
and excluded.route_id is distinct from device_discoveries.route_id
|
||||
then device_discoveries.last_observed_at
|
||||
else greatest(
|
||||
device_discoveries.last_observed_at,
|
||||
excluded.last_observed_at
|
||||
)
|
||||
end,
|
||||
evidence = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
and excluded.route_id is distinct from device_discoveries.route_id
|
||||
then device_discoveries.evidence
|
||||
else excluded.evidence
|
||||
end,
|
||||
session_ref = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
and excluded.route_id is distinct from device_discoveries.route_id
|
||||
then device_discoveries.session_ref
|
||||
else excluded.session_ref
|
||||
end,
|
||||
project_id = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
then device_discoveries.project_id
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and (
|
||||
device_discoveries.enrollment_intent_id is null
|
||||
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
|
||||
or device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
) then excluded.project_id
|
||||
when device_discoveries.project_id is null
|
||||
then excluded.project_id
|
||||
else device_discoveries.project_id
|
||||
end,
|
||||
route_id = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
then device_discoveries.route_id
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and (
|
||||
device_discoveries.enrollment_intent_id is null
|
||||
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
|
||||
or device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
) then excluded.route_id
|
||||
when device_discoveries.route_id is null
|
||||
then excluded.route_id
|
||||
else device_discoveries.route_id
|
||||
end,
|
||||
enrollment_intent_id = case
|
||||
when device_discoveries.lifecycle_state = 'claimed'
|
||||
then device_discoveries.enrollment_intent_id
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and (
|
||||
device_discoveries.enrollment_intent_id is null
|
||||
or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id
|
||||
or device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
) then excluded.enrollment_intent_id
|
||||
else device_discoveries.enrollment_intent_id
|
||||
end,
|
||||
lifecycle_state = case
|
||||
when device_discoveries.lifecycle_state = 'claimed' then 'claimed'
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
then 'quarantine'
|
||||
else device_discoveries.lifecycle_state
|
||||
end,
|
||||
resolution_code = case
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
then null
|
||||
else device_discoveries.resolution_code
|
||||
end,
|
||||
resolved_at = case
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
then null
|
||||
else device_discoveries.resolved_at
|
||||
end,
|
||||
resolved_by_ref = case
|
||||
when excluded.enrollment_intent_id is not null
|
||||
and device_discoveries.lifecycle_state in ('rejected', 'expired')
|
||||
then null
|
||||
else device_discoveries.resolved_by_ref
|
||||
end,
|
||||
updated_at = now()
|
||||
returning id, lifecycle_state, model_profile_ref, protocol,
|
||||
identifier_kind, identifier_masked, first_observed_at,
|
||||
last_observed_at, evidence, project_id, route_id,
|
||||
enrollment_intent_id, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.identifier.masked,
|
||||
safeView.modelProfileRef,
|
||||
safeView.protocol,
|
||||
safeView.observedAt,
|
||||
JSON.stringify(safeView.evidence),
|
||||
sessionRef,
|
||||
route?.project_id ?? null,
|
||||
route?.id ?? null,
|
||||
enrollment?.id ?? null,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (
|
||||
enrollment
|
||||
&& row.enrollment_intent_id !== enrollment.id
|
||||
) {
|
||||
throw domainError("device_discovery_enrollment_conflict", 409);
|
||||
}
|
||||
if (enrollment && row.lifecycle_state === "quarantine") {
|
||||
const observed = await client.query(
|
||||
`update device_enrollment_intents
|
||||
set lifecycle_state = 'observed',
|
||||
observed_discovery_id = $2,
|
||||
observed_at = greatest(coalesce(observed_at, $3), $3),
|
||||
resolution_code = null,
|
||||
resolved_at = null,
|
||||
resolved_by_ref = null,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and lifecycle_state in ('pending', 'observed')
|
||||
returning id`,
|
||||
[enrollment.id, row.id, safeView.observedAt],
|
||||
);
|
||||
if (!observed.rows[0]) {
|
||||
throw domainError("device_enrollment_not_observable", 409);
|
||||
}
|
||||
}
|
||||
await client.query("commit");
|
||||
return {
|
||||
created: row.created === true,
|
||||
value: discoveryView(row),
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveRoute(client, routeId, safeView) {
|
||||
const result = await client.query(
|
||||
`select id, project_id, model_profile_ref, protocol, lifecycle_state
|
||||
from device_routes
|
||||
where id = $1
|
||||
for share`,
|
||||
[routeId],
|
||||
);
|
||||
const route = result.rows[0];
|
||||
if (!route) throw domainError("device_discovery_route_not_found", 404);
|
||||
if (route.lifecycle_state !== "active") {
|
||||
throw domainError("device_discovery_route_inactive", 409);
|
||||
}
|
||||
if (
|
||||
route.model_profile_ref !== safeView.modelProfileRef
|
||||
|| route.protocol !== safeView.protocol
|
||||
) {
|
||||
throw domainError("device_discovery_route_profile_mismatch", 409);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
async function findMatchingEnrollment(client, {
|
||||
route,
|
||||
identifierDigest,
|
||||
safeView,
|
||||
}) {
|
||||
await client.query(
|
||||
`update device_enrollment_intents
|
||||
set lifecycle_state = 'expired',
|
||||
resolution_code = 'deadline_elapsed',
|
||||
resolved_at = $6,
|
||||
updated_at = now()
|
||||
where project_id = $1
|
||||
and route_id = $2
|
||||
and model_profile_ref = $3
|
||||
and expected_identifier_kind = $4
|
||||
and expected_identifier_digest = $5
|
||||
and lifecycle_state = 'pending'
|
||||
and expires_at is not null
|
||||
and expires_at <= $6`,
|
||||
[
|
||||
route.project_id,
|
||||
route.id,
|
||||
safeView.modelProfileRef,
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.observedAt,
|
||||
],
|
||||
);
|
||||
const result = await client.query(
|
||||
`select id, project_id, route_id, model_profile_ref, lifecycle_state
|
||||
from device_enrollment_intents
|
||||
where project_id = $1
|
||||
and route_id = $2
|
||||
and model_profile_ref = $3
|
||||
and expected_identifier_kind = $4
|
||||
and expected_identifier_digest = $5
|
||||
and lifecycle_state in ('pending', 'observed')
|
||||
and (expires_at is null or expires_at > $6)
|
||||
for update`,
|
||||
[
|
||||
route.project_id,
|
||||
route.id,
|
||||
safeView.modelProfileRef,
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.observedAt,
|
||||
],
|
||||
);
|
||||
if (result.rows.length > 1) {
|
||||
throw domainError("device_enrollment_identity_ambiguous", 409);
|
||||
}
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
function discoveryView(row) {
|
||||
return {
|
||||
schemaVersion: "nodedc.device.discovery-view.v1",
|
||||
discoveryRef: `discovery:${row.id}`,
|
||||
...(row.route_id ? { routeRef: `route:${row.route_id}` } : {}),
|
||||
...(row.enrollment_intent_id
|
||||
? { enrollmentIntentRef: `enrollment-intent:${row.enrollment_intent_id}` }
|
||||
: {}),
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
protocol: row.protocol,
|
||||
observedAt: new Date(row.last_observed_at).toISOString(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
identifier: {
|
||||
kind: row.identifier_kind,
|
||||
masked: row.identifier_masked,
|
||||
},
|
||||
evidence: row.evidence,
|
||||
commandTransport: "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = value.match(new RegExp(
|
||||
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
|
||||
"i",
|
||||
));
|
||||
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export const DEVICE_LIFECYCLE_COMMAND_KINDS = Object.freeze([
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
"discovery.reject",
|
||||
"discovery.expire",
|
||||
]);
|
||||
|
||||
const commandKindSet = new Set(DEVICE_LIFECYCLE_COMMAND_KINDS);
|
||||
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||
|
||||
export function isLifecycleManagementCommand(kind) {
|
||||
return commandKindSet.has(kind);
|
||||
}
|
||||
|
||||
export function normalizeLifecycleManagementCommand(kind, input) {
|
||||
if (!commandKindSet.has(kind)) {
|
||||
throw new TypeError("device_lifecycle_command_kind_invalid");
|
||||
}
|
||||
assertPlainObject(input);
|
||||
|
||||
if (kind === "device.claim") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"enrollmentIntentRef",
|
||||
"discoveryRef",
|
||||
"deviceKey",
|
||||
"displayName",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
enrollmentIntentId: normalizeEntityRef(
|
||||
input.enrollmentIntentRef,
|
||||
"enrollment-intent",
|
||||
),
|
||||
discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"),
|
||||
deviceKey: normalizePattern(
|
||||
input.deviceKey,
|
||||
keyPattern,
|
||||
"device_key_invalid",
|
||||
),
|
||||
displayName: normalizeDisplayText(input.displayName, 160),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device.transfer") {
|
||||
assertAllowedKeys(input, [
|
||||
"deviceRef",
|
||||
"sourceProjectRef",
|
||||
"targetProjectRef",
|
||||
"targetDeviceKey",
|
||||
]);
|
||||
const sourceProjectId = normalizeEntityRef(
|
||||
input.sourceProjectRef,
|
||||
"project",
|
||||
);
|
||||
const targetProjectId = normalizeEntityRef(
|
||||
input.targetProjectRef,
|
||||
"project",
|
||||
);
|
||||
if (sourceProjectId === targetProjectId) {
|
||||
throw new TypeError("device_transfer_target_same_as_source");
|
||||
}
|
||||
return Object.freeze({
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
sourceProjectId,
|
||||
targetProjectId,
|
||||
targetDeviceKey: normalizePattern(
|
||||
input.targetDeviceKey,
|
||||
keyPattern,
|
||||
"device_transfer_target_key_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"discoveryRef",
|
||||
"resolutionCode",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"),
|
||||
resolutionCode: normalizePattern(
|
||||
input.resolutionCode,
|
||||
resolutionPattern,
|
||||
"device_discovery_resolution_code_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = value.match(new RegExp(
|
||||
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
|
||||
"i",
|
||||
));
|
||||
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizePattern(value, pattern, code) {
|
||||
if (typeof value !== "string" || !pattern.test(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDisplayText(value, maxLength) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError("device_display_name_invalid");
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (
|
||||
normalized.length < 1
|
||||
|| normalized.length > maxLength
|
||||
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
|
||||
) {
|
||||
throw new TypeError("device_display_name_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertPlainObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_lifecycle_command_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedKeys(input, allowed) {
|
||||
const allowedSet = new Set(allowed);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedSet.has(key)) {
|
||||
throw new TypeError(`device_management_command_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { isLifecycleManagementCommand } from "./lifecycle-management.mjs";
|
||||
import {
|
||||
assertProjectCapability,
|
||||
toProjectRef,
|
||||
} from "./project-management.mjs";
|
||||
|
||||
export async function applyLifecycleManagementCommand(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isLifecycleManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_lifecycle_command_kind_invalid");
|
||||
}
|
||||
if (commandKind === "device.claim") {
|
||||
return claimDevice(client, actor, command);
|
||||
}
|
||||
if (commandKind === "device.transfer") {
|
||||
return transferDevice(client, actor, command);
|
||||
}
|
||||
return resolveDiscovery(client, actor, command, commandKind);
|
||||
}
|
||||
|
||||
export async function authorizeLifecycleManagementReplay(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isLifecycleManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_lifecycle_command_kind_invalid");
|
||||
}
|
||||
if (commandKind === "device.transfer") {
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.sourceProjectId,
|
||||
"device.transfer",
|
||||
);
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.targetProjectId,
|
||||
"device.transfer",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"device.claim",
|
||||
);
|
||||
if (commandKind === "device.claim") {
|
||||
const current = await client.query(
|
||||
`select di.project_id
|
||||
from device_discoveries dd
|
||||
join device_instances di on di.id = dd.claimed_device_id
|
||||
where dd.id = $1`,
|
||||
[command.discoveryId],
|
||||
);
|
||||
const currentProjectId = current.rows[0]?.project_id;
|
||||
if (currentProjectId && currentProjectId !== command.projectId) {
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
currentProjectId,
|
||||
"device.claim",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function claimDevice(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"device.claim",
|
||||
);
|
||||
const enrollment = await findEnrollmentForUpdate(
|
||||
client,
|
||||
command.projectId,
|
||||
command.enrollmentIntentId,
|
||||
);
|
||||
const discovery = await findDiscoveryForUpdate(
|
||||
client,
|
||||
command.projectId,
|
||||
command.discoveryId,
|
||||
);
|
||||
assertClaimEvidence(command, enrollment, discovery);
|
||||
|
||||
const deviceId = randomUUID();
|
||||
const inserted = await client.query(
|
||||
`insert into device_instances (
|
||||
id,
|
||||
contour_id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
device_key,
|
||||
model_profile_ref,
|
||||
display_name,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
lifecycle_state
|
||||
) values ($1, null, $2, $3, $4, $5, $6, $7, $8, $9, 'claimed')
|
||||
returning id, owner_scope_id, project_id, device_key,
|
||||
model_profile_ref, display_name, identifier_kind,
|
||||
identifier_masked, lifecycle_state, created_at, updated_at`,
|
||||
[
|
||||
deviceId,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
command.deviceKey,
|
||||
discovery.model_profile_ref,
|
||||
command.displayName,
|
||||
discovery.identifier_kind,
|
||||
discovery.identifier_digest,
|
||||
discovery.identifier_masked,
|
||||
],
|
||||
);
|
||||
const device = inserted.rows[0];
|
||||
if (!device) throw domainError("device_claim_insert_failed", 409);
|
||||
|
||||
const claimedDiscovery = await client.query(
|
||||
`update device_discoveries
|
||||
set lifecycle_state = 'claimed',
|
||||
claimed_device_id = $2,
|
||||
claimed_at = now(),
|
||||
claimed_by = $3,
|
||||
resolution_code = 'claimed',
|
||||
resolved_at = now(),
|
||||
resolved_by_ref = $3,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and lifecycle_state = 'quarantine'
|
||||
and enrollment_intent_id = $4
|
||||
returning id`,
|
||||
[discovery.id, device.id, actor.userRef, enrollment.id],
|
||||
);
|
||||
if (!claimedDiscovery.rows[0]) {
|
||||
throw domainError("device_discovery_not_claimable", 409);
|
||||
}
|
||||
|
||||
const claimedEnrollment = await client.query(
|
||||
`update device_enrollment_intents
|
||||
set lifecycle_state = 'claimed',
|
||||
claimed_device_id = $2,
|
||||
claimed_at = now(),
|
||||
resolution_code = 'claimed',
|
||||
resolved_at = now(),
|
||||
resolved_by_ref = $3,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and lifecycle_state = 'observed'
|
||||
and observed_discovery_id = $4
|
||||
and (expires_at is null or expires_at > now())
|
||||
returning id`,
|
||||
[enrollment.id, device.id, actor.userRef, discovery.id],
|
||||
);
|
||||
if (!claimedEnrollment.rows[0]) {
|
||||
throw domainError("device_enrollment_not_claimable", 409);
|
||||
}
|
||||
|
||||
const transitionId = randomUUID();
|
||||
await client.query(
|
||||
`insert into device_ownership_transitions (
|
||||
id,
|
||||
device_id,
|
||||
transition_kind,
|
||||
target_owner_scope_id,
|
||||
target_project_id,
|
||||
actor_ref
|
||||
) values ($1, $2, 'claim', $3, $4, $5)`,
|
||||
[
|
||||
transitionId,
|
||||
device.id,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
await addAudit(client, {
|
||||
eventType: "device.claimed",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
discoveryId: discovery.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||
discoveryRef: `discovery:${discovery.id}`,
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
modelProfileRef: device.model_profile_ref,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: true,
|
||||
device: deviceView(device, project),
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||
discoveryRef: `discovery:${discovery.id}`,
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDiscovery(client, actor, command, commandKind) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"device.claim",
|
||||
);
|
||||
const discovery = await findDiscoveryForUpdate(
|
||||
client,
|
||||
command.projectId,
|
||||
command.discoveryId,
|
||||
);
|
||||
if (
|
||||
discovery.lifecycle_state !== "quarantine"
|
||||
|| !discovery.enrollment_intent_id
|
||||
) {
|
||||
throw domainError("device_discovery_not_resolvable", 409);
|
||||
}
|
||||
const enrollment = await findEnrollmentForUpdate(
|
||||
client,
|
||||
command.projectId,
|
||||
discovery.enrollment_intent_id,
|
||||
);
|
||||
if (
|
||||
enrollment.lifecycle_state !== "observed"
|
||||
|| enrollment.observed_discovery_id !== discovery.id
|
||||
) {
|
||||
throw domainError("device_enrollment_not_resolvable", 409);
|
||||
}
|
||||
|
||||
const discoveryState = commandKind === "discovery.reject"
|
||||
? "rejected"
|
||||
: "expired";
|
||||
const enrollmentState = commandKind === "discovery.reject"
|
||||
? "cancelled"
|
||||
: "expired";
|
||||
await client.query(
|
||||
`update device_discoveries
|
||||
set lifecycle_state = $2,
|
||||
resolution_code = $3,
|
||||
resolved_at = now(),
|
||||
resolved_by_ref = $4,
|
||||
updated_at = now()
|
||||
where id = $1 and lifecycle_state = 'quarantine'`,
|
||||
[discovery.id, discoveryState, command.resolutionCode, actor.userRef],
|
||||
);
|
||||
await client.query(
|
||||
`update device_enrollment_intents
|
||||
set lifecycle_state = $2,
|
||||
resolution_code = $3,
|
||||
resolved_at = now(),
|
||||
resolved_by_ref = $4,
|
||||
updated_at = now()
|
||||
where id = $1 and lifecycle_state = 'observed'`,
|
||||
[enrollment.id, enrollmentState, command.resolutionCode, actor.userRef],
|
||||
);
|
||||
await addAudit(client, {
|
||||
eventType: `discovery.${discoveryState}`,
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
discoveryId: discovery.id,
|
||||
payload: {
|
||||
projectRef: toProjectRef(project.id),
|
||||
discoveryRef: `discovery:${discovery.id}`,
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||
lifecycleState: discoveryState,
|
||||
resolutionCode: command.resolutionCode,
|
||||
},
|
||||
});
|
||||
return {
|
||||
discovery: {
|
||||
discoveryRef: `discovery:${discovery.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||
lifecycleState: discoveryState,
|
||||
identifier: {
|
||||
kind: discovery.identifier_kind,
|
||||
masked: discovery.identifier_masked,
|
||||
},
|
||||
resolutionCode: command.resolutionCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function transferDevice(client, actor, command) {
|
||||
const device = await findDeviceForUpdate(client, command.deviceId);
|
||||
if (device.project_id !== command.sourceProjectId) {
|
||||
throw domainError("device_transfer_source_mismatch", 409);
|
||||
}
|
||||
if (!device.owner_scope_id || !device.project_id || device.contour_id) {
|
||||
throw domainError("device_transfer_legacy_ownership_unsupported", 409);
|
||||
}
|
||||
if (["online", "retired"].includes(device.lifecycle_state)) {
|
||||
throw domainError("device_transfer_lifecycle_blocked", 409);
|
||||
}
|
||||
|
||||
const sourceProject = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.sourceProjectId,
|
||||
"device.transfer",
|
||||
);
|
||||
const targetProject = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.targetProjectId,
|
||||
"device.transfer",
|
||||
);
|
||||
if (sourceProject.owner_scope_id !== device.owner_scope_id) {
|
||||
throw domainError("device_transfer_owner_mismatch", 409);
|
||||
}
|
||||
|
||||
const activeSessions = await client.query(
|
||||
`select exists (
|
||||
select 1 from device_sessions
|
||||
where device_id = $1
|
||||
and lifecycle_state in ('connecting', 'online', 'closing')
|
||||
) as active`,
|
||||
[device.id],
|
||||
);
|
||||
if (activeSessions.rows[0]?.active === true) {
|
||||
throw domainError("device_transfer_active_session", 409);
|
||||
}
|
||||
|
||||
const detached = await client.query(
|
||||
`delete from device_collection_members
|
||||
where device_id = $1 and project_id = $2`,
|
||||
[device.id, sourceProject.id],
|
||||
);
|
||||
const updated = await client.query(
|
||||
`update device_instances
|
||||
set owner_scope_id = $2,
|
||||
project_id = $3,
|
||||
device_key = $4,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning id, contour_id, owner_scope_id, project_id, device_key,
|
||||
model_profile_ref, display_name, identifier_kind,
|
||||
identifier_masked, lifecycle_state, created_at, updated_at`,
|
||||
[
|
||||
device.id,
|
||||
targetProject.owner_scope_id,
|
||||
targetProject.id,
|
||||
command.targetDeviceKey,
|
||||
],
|
||||
);
|
||||
const moved = updated.rows[0];
|
||||
if (!moved) throw domainError("device_transfer_update_failed", 409);
|
||||
|
||||
const transitionId = randomUUID();
|
||||
await client.query(
|
||||
`insert into device_ownership_transitions (
|
||||
id,
|
||||
device_id,
|
||||
transition_kind,
|
||||
source_owner_scope_id,
|
||||
source_project_id,
|
||||
target_owner_scope_id,
|
||||
target_project_id,
|
||||
actor_ref
|
||||
) values ($1, $2, 'transfer', $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
transitionId,
|
||||
device.id,
|
||||
sourceProject.owner_scope_id,
|
||||
sourceProject.id,
|
||||
targetProject.owner_scope_id,
|
||||
targetProject.id,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const auditPayload = {
|
||||
deviceRef: `device:${device.id}`,
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
sourceProjectRef: toProjectRef(sourceProject.id),
|
||||
targetProjectRef: toProjectRef(targetProject.id),
|
||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||
};
|
||||
await addAudit(client, {
|
||||
eventType: "device.transferred_out",
|
||||
actorRef: actor.userRef,
|
||||
projectId: sourceProject.id,
|
||||
deviceId: device.id,
|
||||
payload: auditPayload,
|
||||
});
|
||||
await addAudit(client, {
|
||||
eventType: "device.transferred_in",
|
||||
actorRef: actor.userRef,
|
||||
projectId: targetProject.id,
|
||||
deviceId: device.id,
|
||||
payload: auditPayload,
|
||||
});
|
||||
return {
|
||||
transferred: true,
|
||||
device: deviceView(moved, targetProject),
|
||||
sourceProjectRef: toProjectRef(sourceProject.id),
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function findProjectWithCapability(client, actor, projectId, capability) {
|
||||
const result = await client.query(
|
||||
`select p.id, p.owner_scope_id, p.lifecycle_state,
|
||||
os.scope_kind, os.owner_ref, os.display_name as owner_display_name,
|
||||
os.lifecycle_state as owner_lifecycle_state
|
||||
from device_projects p
|
||||
join device_owner_scopes os on os.id = p.owner_scope_id
|
||||
where p.id = $1
|
||||
for share of p, os`,
|
||||
[projectId],
|
||||
);
|
||||
const project = result.rows[0];
|
||||
if (!project) throw domainError("device_project_not_found", 404);
|
||||
if (project.owner_lifecycle_state !== "active") {
|
||||
throw domainError("device_owner_scope_inactive", 409);
|
||||
}
|
||||
if (project.lifecycle_state !== "active") {
|
||||
throw domainError("device_project_inactive", 409);
|
||||
}
|
||||
const grants = await client.query(
|
||||
`select id, principal_kind, principal_ref, project_role,
|
||||
capability_allow, capability_deny, lifecycle_state
|
||||
from device_project_grants
|
||||
where project_id = $1
|
||||
order by created_at, id
|
||||
for share`,
|
||||
[projectId],
|
||||
);
|
||||
assertProjectCapability(
|
||||
actor,
|
||||
grants.rows.map((grant) => ({
|
||||
grantRef: `grant:${grant.id}`,
|
||||
principalKind: grant.principal_kind,
|
||||
principalRef: grant.principal_ref,
|
||||
projectRole: grant.project_role,
|
||||
capabilityAllow: grant.capability_allow ?? [],
|
||||
capabilityDeny: grant.capability_deny ?? [],
|
||||
lifecycleState: grant.lifecycle_state,
|
||||
})),
|
||||
capability,
|
||||
);
|
||||
return project;
|
||||
}
|
||||
|
||||
async function findEnrollmentForUpdate(client, projectId, enrollmentId) {
|
||||
const result = await client.query(
|
||||
`select id, project_id, route_id, model_profile_ref,
|
||||
expected_identifier_kind, expected_identifier_digest,
|
||||
expected_identifier_masked, lifecycle_state,
|
||||
observed_discovery_id, claimed_device_id, expires_at
|
||||
from device_enrollment_intents
|
||||
where id = $1 and project_id = $2
|
||||
for update`,
|
||||
[enrollmentId, projectId],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw domainError("device_enrollment_intent_not_found", 404);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findDiscoveryForUpdate(client, projectId, discoveryId) {
|
||||
const result = await client.query(
|
||||
`select id, project_id, route_id, enrollment_intent_id,
|
||||
model_profile_ref, protocol, identifier_kind, identifier_digest,
|
||||
identifier_masked, lifecycle_state, claimed_device_id
|
||||
from device_discoveries
|
||||
where id = $1 and project_id = $2
|
||||
for update`,
|
||||
[discoveryId, projectId],
|
||||
);
|
||||
if (!result.rows[0]) throw domainError("device_discovery_not_found", 404);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findDeviceForUpdate(client, deviceId) {
|
||||
const result = await client.query(
|
||||
`select id, contour_id, owner_scope_id, project_id, device_key,
|
||||
model_profile_ref, display_name, identifier_kind,
|
||||
identifier_masked, lifecycle_state, created_at, updated_at
|
||||
from device_instances
|
||||
where id = $1
|
||||
for update`,
|
||||
[deviceId],
|
||||
);
|
||||
if (!result.rows[0]) throw domainError("device_not_found", 404);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
function assertClaimEvidence(command, enrollment, discovery) {
|
||||
if (
|
||||
enrollment.lifecycle_state !== "observed"
|
||||
|| enrollment.observed_discovery_id !== discovery.id
|
||||
|| discovery.lifecycle_state !== "quarantine"
|
||||
|| discovery.enrollment_intent_id !== enrollment.id
|
||||
|| discovery.project_id !== command.projectId
|
||||
|| discovery.route_id !== enrollment.route_id
|
||||
|| discovery.model_profile_ref !== enrollment.model_profile_ref
|
||||
|| discovery.identifier_kind !== enrollment.expected_identifier_kind
|
||||
|| discovery.identifier_digest !== enrollment.expected_identifier_digest
|
||||
|| discovery.identifier_masked !== enrollment.expected_identifier_masked
|
||||
) {
|
||||
throw domainError("device_claim_evidence_mismatch", 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function addAudit(client, {
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId = null,
|
||||
discoveryId = null,
|
||||
payload,
|
||||
}) {
|
||||
await client.query(
|
||||
`insert into device_audit_events (
|
||||
id,
|
||||
event_type,
|
||||
actor_ref,
|
||||
project_id,
|
||||
device_id,
|
||||
discovery_id,
|
||||
payload
|
||||
) values ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||||
[
|
||||
randomUUID(),
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId,
|
||||
discoveryId,
|
||||
JSON.stringify(payload),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function deviceView(row, project) {
|
||||
return {
|
||||
deviceRef: `device:${row.id}`,
|
||||
deviceKey: row.device_key,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
ownerScope: {
|
||||
ownerScopeRef: `owner-scope:${row.owner_scope_id}`,
|
||||
scopeKind: project.scope_kind,
|
||||
ownerRef: project.owner_ref,
|
||||
},
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
displayName: row.display_name,
|
||||
identifier: {
|
||||
kind: row.identifier_kind,
|
||||
masked: row.identifier_masked,
|
||||
},
|
||||
lifecycleState: row.lifecycle_state,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
isInfrastructureManagementCommand,
|
||||
normalizeInfrastructureManagementCommand,
|
||||
} from "./infrastructure-management.mjs";
|
||||
import {
|
||||
DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
isLifecycleManagementCommand,
|
||||
normalizeLifecycleManagementCommand,
|
||||
} from "./lifecycle-management.mjs";
|
||||
import {
|
||||
DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
normalizeManagementCommand,
|
||||
@@ -11,9 +16,13 @@ import {
|
||||
export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
||||
...DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
||||
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
]);
|
||||
|
||||
export function normalizeDeviceManagementCommand(kind, input) {
|
||||
if (isLifecycleManagementCommand(kind)) {
|
||||
return normalizeLifecycleManagementCommand(kind, input);
|
||||
}
|
||||
if (isInfrastructureManagementCommand(kind)) {
|
||||
return normalizeInfrastructureManagementCommand(kind, input);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,17 @@ import { fileURLToPath } from "node:url";
|
||||
import pg from "pg";
|
||||
|
||||
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
||||
import { observeQuarantineDiscovery } from "./discovery-repository.mjs";
|
||||
import {
|
||||
applyInfrastructureManagementCommand,
|
||||
authorizeInfrastructureManagementReplay,
|
||||
} from "./infrastructure-repository.mjs";
|
||||
import { isInfrastructureManagementCommand } from "./infrastructure-management.mjs";
|
||||
import { isLifecycleManagementCommand } from "./lifecycle-management.mjs";
|
||||
import {
|
||||
applyLifecycleManagementCommand,
|
||||
authorizeLifecycleManagementReplay,
|
||||
} from "./lifecycle-repository.mjs";
|
||||
import {
|
||||
assertActorCanManageOwnerScope,
|
||||
assertGrantMutationAllowed,
|
||||
@@ -26,6 +32,8 @@ const migrationFiles = [
|
||||
"003_device_management_commands.sql",
|
||||
"004_device_registry_foundation.sql",
|
||||
"005_device_registry_commands.sql",
|
||||
"006_device_lifecycle_ownership.sql",
|
||||
"007_device_lifecycle_commands.sql",
|
||||
];
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
@@ -89,60 +97,11 @@ export class PostgresDeviceRepository {
|
||||
return "ready";
|
||||
}
|
||||
|
||||
async upsertQuarantineDiscovery({ identifierDigest, safeView }) {
|
||||
const result = await this.pool.query(
|
||||
`insert into device_discoveries (
|
||||
id,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
model_profile_ref,
|
||||
protocol,
|
||||
lifecycle_state,
|
||||
first_observed_at,
|
||||
last_observed_at,
|
||||
evidence
|
||||
) values ($1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb)
|
||||
on conflict (identifier_kind, identifier_digest, model_profile_ref)
|
||||
do update set
|
||||
last_observed_at = greatest(
|
||||
device_discoveries.last_observed_at,
|
||||
excluded.last_observed_at
|
||||
),
|
||||
evidence = excluded.evidence,
|
||||
updated_at = now()
|
||||
returning id, lifecycle_state, model_profile_ref, protocol,
|
||||
identifier_kind, identifier_masked, first_observed_at,
|
||||
last_observed_at, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.identifier.masked,
|
||||
safeView.modelProfileRef,
|
||||
safeView.protocol,
|
||||
safeView.observedAt,
|
||||
JSON.stringify(safeView.evidence),
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
created: row.created === true,
|
||||
value: {
|
||||
schemaVersion: "nodedc.device.discovery-view.v1",
|
||||
discoveryRef: `discovery:${row.id}`,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
protocol: row.protocol,
|
||||
observedAt: new Date(row.last_observed_at).toISOString(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
identifier: {
|
||||
kind: row.identifier_kind,
|
||||
masked: row.identifier_masked,
|
||||
},
|
||||
evidence: safeView.evidence,
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
};
|
||||
async upsertQuarantineDiscovery(input) {
|
||||
return observeQuarantineDiscovery({
|
||||
pool: this.pool,
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
async executeManagementCommand({
|
||||
@@ -258,6 +217,13 @@ async function completeManagementReceipt(client, receiptId, result) {
|
||||
}
|
||||
|
||||
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||
if (isLifecycleManagementCommand(commandKind)) {
|
||||
return applyLifecycleManagementCommand(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isInfrastructureManagementCommand(commandKind)) {
|
||||
return applyInfrastructureManagementCommand(client, {
|
||||
commandKind,
|
||||
@@ -281,6 +247,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||
}
|
||||
|
||||
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
||||
if (isLifecycleManagementCommand(commandKind)) {
|
||||
return authorizeLifecycleManagementReplay(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isInfrastructureManagementCommand(commandKind)) {
|
||||
return authorizeInfrastructureManagementReplay(client, {
|
||||
commandKind,
|
||||
|
||||
@@ -310,6 +310,8 @@ test("authenticated ingest stores only digest and returns a masked view", async
|
||||
assert.equal(serialized.includes(fakeImei), false);
|
||||
assert.equal(body.discovery.identifier.masked, "***********0001");
|
||||
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(stored.sessionRef, "session:test-001");
|
||||
assert.equal(stored.routeRef, null);
|
||||
assert.equal(JSON.stringify(stored).includes(fakeImei), false);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/007_device_lifecycle_commands.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("lifecycle command migration extends the durable receipt allowlist", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
for (const kind of [
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
"discovery.reject",
|
||||
"discovery.expire",
|
||||
]) {
|
||||
assert.match(sql, new RegExp(`'${kind.replace(".", "\\.")}'`));
|
||||
}
|
||||
});
|
||||
|
||||
test("lifecycle command migration contains no runtime entity or secret", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.doesNotMatch(sql, /insert\s+into/i);
|
||||
assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i);
|
||||
assert.doesNotMatch(sql, /password|secret|credential|private_key/i);
|
||||
});
|
||||
|
||||
test("repository applies lifecycle commands after ownership schema", async () => {
|
||||
const source = await readFile(repositoryUrl, "utf8");
|
||||
const lifecycleIndex = source.indexOf("006_device_lifecycle_ownership.sql");
|
||||
const commandsIndex = source.indexOf("007_device_lifecycle_commands.sql");
|
||||
|
||||
assert.notEqual(lifecycleIndex, -1);
|
||||
assert.notEqual(commandsIndex, -1);
|
||||
assert.ok(lifecycleIndex < commandsIndex);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/006_device_lifecycle_ownership.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("lifecycle migration separates direct ownership from legacy contours", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.match(sql, /alter column contour_id drop not null/);
|
||||
assert.match(sql, /add column if not exists owner_scope_id uuid/);
|
||||
assert.match(sql, /device_instances_project_owner_fk/);
|
||||
assert.match(sql, /device_instances_ownership_mode_check/);
|
||||
assert.match(sql, /references device_projects\(id, owner_scope_id\)/);
|
||||
assert.match(sql, /\) not valid;/);
|
||||
});
|
||||
|
||||
test("route-bound discovery and enrollment evidence are DB constrained", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.match(sql, /device_discoveries_route_context_fk/);
|
||||
assert.match(sql, /device_discoveries_enrollment_context_fk/);
|
||||
assert.match(sql, /device_enrollment_observed_discovery_fk/);
|
||||
assert.match(sql, /device_enrollment_intents_active_identity_idx/);
|
||||
assert.match(sql, /where lifecycle_state in \('pending', 'observed', 'claimed'\)/);
|
||||
});
|
||||
|
||||
test("ownership history supports transfer without rewriting session provenance", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.match(sql, /create table if not exists device_ownership_transitions/);
|
||||
assert.match(sql, /transition_kind in \('claim', 'transfer'\)/);
|
||||
assert.match(sql, /device_ownership_single_claim_idx/);
|
||||
assert.match(sql, /device_assert_session_current_project/);
|
||||
assert.match(sql, /device_assert_enrollment_current_project/);
|
||||
assert.match(sql, /drop constraint if exists device_sessions_device_id_project_id_fkey/);
|
||||
});
|
||||
|
||||
test("lifecycle migration contains no tenant, device, route or credential seed", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.doesNotMatch(sql, /insert\s+into/i);
|
||||
assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i);
|
||||
assert.doesNotMatch(sql, /155\.212\.|device\.nodedc\.ru|synology/i);
|
||||
assert.doesNotMatch(sql, /password|secret|private_key|credential_ref/i);
|
||||
});
|
||||
|
||||
test("repository applies lifecycle migration after registry commands", async () => {
|
||||
const source = await readFile(repositoryUrl, "utf8");
|
||||
const commandsIndex = source.indexOf("005_device_registry_commands.sql");
|
||||
const lifecycleIndex = source.indexOf("006_device_lifecycle_ownership.sql");
|
||||
|
||||
assert.notEqual(commandsIndex, -1);
|
||||
assert.notEqual(lifecycleIndex, -1);
|
||||
assert.ok(commandsIndex < lifecycleIndex);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { observeQuarantineDiscovery } from "../src/discovery-repository.mjs";
|
||||
|
||||
const observedAt = "2026-08-10T00:00:00.000Z";
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
const routeId = "22222222-2222-4222-8222-222222222222";
|
||||
const enrollmentId = "33333333-3333-4333-8333-333333333333";
|
||||
const discoveryId = "44444444-4444-4444-8444-444444444444";
|
||||
const identifierDigest = `hmac-sha256:${"a".repeat(64)}`;
|
||||
|
||||
test("observation expires stale intents before matching an identity", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/discovery-repository.mjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(source, /expires_at <= \$6/);
|
||||
assert.match(source, /expires_at is null or expires_at > \$6/);
|
||||
assert.match(source, /resolution_code = 'deadline_elapsed'/);
|
||||
});
|
||||
|
||||
test("legacy discovery remains unbound quarantine without a route reference", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_discoveries", {
|
||||
rows: [discoveryRow({ project_id: null, route_id: null })],
|
||||
}),
|
||||
step("commit"),
|
||||
]);
|
||||
|
||||
const result = await observeQuarantineDiscovery({
|
||||
pool: poolWithClient(client),
|
||||
identifierDigest,
|
||||
safeView: safeView(),
|
||||
sessionRef: "session:legacy-test",
|
||||
});
|
||||
|
||||
assert.equal(result.created, true);
|
||||
assert.equal("routeRef" in result.value, false);
|
||||
assert.equal("enrollmentIntentRef" in result.value, false);
|
||||
assertSafeProjection(result.value);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("route-bound discovery atomically observes only its matching enrollment", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("from device_routes", {
|
||||
rows: [{
|
||||
id: routeId,
|
||||
project_id: projectId,
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
lifecycle_state: "active",
|
||||
}],
|
||||
}),
|
||||
step("update device_enrollment_intents"),
|
||||
step("from device_enrollment_intents", {
|
||||
rows: [{
|
||||
id: enrollmentId,
|
||||
project_id: projectId,
|
||||
route_id: routeId,
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
lifecycle_state: "pending",
|
||||
}],
|
||||
}),
|
||||
step("insert into device_discoveries", {
|
||||
rows: [discoveryRow({
|
||||
project_id: projectId,
|
||||
route_id: routeId,
|
||||
enrollment_intent_id: enrollmentId,
|
||||
})],
|
||||
}),
|
||||
step("update device_enrollment_intents", {
|
||||
rows: [{ id: enrollmentId }],
|
||||
}),
|
||||
step("commit"),
|
||||
]);
|
||||
|
||||
const result = await observeQuarantineDiscovery({
|
||||
pool: poolWithClient(client),
|
||||
identifierDigest,
|
||||
safeView: safeView(`route:${routeId}`),
|
||||
sessionRef: "session:route-test",
|
||||
routeRef: `route:${routeId}`,
|
||||
});
|
||||
|
||||
assert.equal(result.value.routeRef, `route:${routeId}`);
|
||||
assert.equal(
|
||||
result.value.enrollmentIntentRef,
|
||||
`enrollment-intent:${enrollmentId}`,
|
||||
);
|
||||
assertSafeProjection(result.value);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("inactive or mismatched routes fail before a discovery is stored", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("from device_routes", {
|
||||
rows: [{
|
||||
id: routeId,
|
||||
project_id: projectId,
|
||||
model_profile_ref: "other.profile.v1",
|
||||
protocol: "OTHER_TCP",
|
||||
lifecycle_state: "active",
|
||||
}],
|
||||
}),
|
||||
step("rollback"),
|
||||
]);
|
||||
|
||||
await assert.rejects(
|
||||
observeQuarantineDiscovery({
|
||||
pool: poolWithClient(client),
|
||||
identifierDigest,
|
||||
safeView: safeView(`route:${routeId}`),
|
||||
sessionRef: "session:mismatch-test",
|
||||
routeRef: `route:${routeId}`,
|
||||
}),
|
||||
/device_discovery_route_profile_mismatch/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
function safeView(routeRef = null) {
|
||||
return {
|
||||
schemaVersion: "nodedc.device.discovery-view.v1",
|
||||
...(routeRef ? { routeRef } : {}),
|
||||
modelProfileRef: "vendor.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
observedAt,
|
||||
lifecycleState: "quarantine",
|
||||
identifier: { kind: "serial", masked: "********0001" },
|
||||
evidence: {
|
||||
transport: "tcp",
|
||||
bytesObserved: 32,
|
||||
framingStatus: "verified",
|
||||
specificationRef: "vendor.protocol.v1",
|
||||
},
|
||||
commandTransport: "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function discoveryRow(overrides = {}) {
|
||||
return {
|
||||
id: discoveryId,
|
||||
lifecycle_state: "quarantine",
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
identifier_kind: "serial",
|
||||
identifier_masked: "********0001",
|
||||
first_observed_at: new Date(observedAt),
|
||||
last_observed_at: new Date(observedAt),
|
||||
evidence: safeView().evidence,
|
||||
project_id: null,
|
||||
route_id: null,
|
||||
enrollment_intent_id: null,
|
||||
created: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function poolWithClient(client) {
|
||||
return { connect: async () => client };
|
||||
}
|
||||
|
||||
function step(includes, result = { rows: [] }) {
|
||||
return { includes, result };
|
||||
}
|
||||
|
||||
function scriptedClient(steps) {
|
||||
const queue = [...steps];
|
||||
return {
|
||||
released: false,
|
||||
async query(sql) {
|
||||
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, "\\$&");
|
||||
}
|
||||
@@ -76,6 +76,46 @@ test("management API exposes no user-owned session mutation", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("management API forwards claim as evidence references without identity input", async () => {
|
||||
let executed;
|
||||
const runtime = await startServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async (input) => {
|
||||
executed = input;
|
||||
return { replayed: false, result: { created: true } };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/devices:claim`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders(),
|
||||
body: JSON.stringify({
|
||||
projectRef: "project:11111111-1111-4111-8111-111111111111",
|
||||
enrollmentIntentRef:
|
||||
"enrollment-intent:22222222-2222-4222-8222-222222222222",
|
||||
discoveryRef: "discovery:33333333-3333-4333-8333-333333333333",
|
||||
deviceKey: "pilot-device",
|
||||
displayName: "Pilot device",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(executed.commandKind, "device.claim");
|
||||
assert.equal(executed.command.deviceKey, "pilot-device");
|
||||
assert.equal("identifier" in executed.command, false);
|
||||
assert.equal("credentialRef" in executed.command, false);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function startServer(options) {
|
||||
const server = createControlCoreApp(options);
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
normalizeLifecycleManagementCommand,
|
||||
} from "../src/lifecycle-management.mjs";
|
||||
import {
|
||||
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
normalizeDeviceManagementCommand,
|
||||
} from "../src/management-command.mjs";
|
||||
|
||||
const projectRef = "project:11111111-1111-4111-8111-111111111111";
|
||||
const targetProjectRef = "project:22222222-2222-4222-8222-222222222222";
|
||||
const discoveryRef = "discovery:33333333-3333-4333-8333-333333333333";
|
||||
const enrollmentIntentRef =
|
||||
"enrollment-intent:44444444-4444-4444-8444-444444444444";
|
||||
const deviceRef = "device:55555555-5555-4555-8555-555555555555";
|
||||
|
||||
test("lifecycle commands join the same strict management command surface", () => {
|
||||
for (const kind of DEVICE_LIFECYCLE_COMMAND_KINDS) {
|
||||
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeDeviceManagementCommand("device.claim", claimInput()).projectId,
|
||||
projectRef.slice("project:".length),
|
||||
);
|
||||
});
|
||||
|
||||
test("claim accepts only opaque evidence references and presentation fields", () => {
|
||||
const command = normalizeLifecycleManagementCommand(
|
||||
"device.claim",
|
||||
claimInput(),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
command.enrollmentIntentId,
|
||||
enrollmentIntentRef.slice("enrollment-intent:".length),
|
||||
);
|
||||
assert.equal(command.discoveryId, discoveryRef.slice("discovery:".length));
|
||||
assert.equal(command.deviceKey, "pilot-device");
|
||||
assert.equal("identifier" in command, false);
|
||||
assert.equal("credentialRef" in command, false);
|
||||
});
|
||||
|
||||
test("claim rejects raw identity and credential-shaped input", () => {
|
||||
assert.throws(
|
||||
() => normalizeLifecycleManagementCommand("device.claim", {
|
||||
...claimInput(),
|
||||
identifier: "000000000000001",
|
||||
}),
|
||||
/device_management_command_field_unexpected:identifier/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeLifecycleManagementCommand("device.claim", {
|
||||
...claimInput(),
|
||||
credentialRef: "secret:test",
|
||||
}),
|
||||
/device_management_command_field_unexpected:credentialRef/,
|
||||
);
|
||||
});
|
||||
|
||||
test("transfer binds both project boundaries and rejects a no-op", () => {
|
||||
const command = normalizeLifecycleManagementCommand("device.transfer", {
|
||||
deviceRef,
|
||||
sourceProjectRef: projectRef,
|
||||
targetProjectRef,
|
||||
targetDeviceKey: "transferred-device",
|
||||
});
|
||||
|
||||
assert.equal(command.deviceId, deviceRef.slice("device:".length));
|
||||
assert.notEqual(command.sourceProjectId, command.targetProjectId);
|
||||
assert.throws(
|
||||
() => normalizeLifecycleManagementCommand("device.transfer", {
|
||||
deviceRef,
|
||||
sourceProjectRef: projectRef,
|
||||
targetProjectRef: projectRef,
|
||||
targetDeviceKey: "same-project",
|
||||
}),
|
||||
/device_transfer_target_same_as_source/,
|
||||
);
|
||||
});
|
||||
|
||||
test("reject and expire require bounded machine-readable resolution codes", () => {
|
||||
for (const kind of ["discovery.reject", "discovery.expire"]) {
|
||||
const command = normalizeLifecycleManagementCommand(kind, {
|
||||
projectRef,
|
||||
discoveryRef,
|
||||
resolutionCode: "operator.identity_mismatch",
|
||||
});
|
||||
assert.equal(command.resolutionCode, "operator.identity_mismatch");
|
||||
}
|
||||
assert.throws(
|
||||
() => normalizeLifecycleManagementCommand("discovery.reject", {
|
||||
projectRef,
|
||||
discoveryRef,
|
||||
resolutionCode: "free form reason is forbidden",
|
||||
}),
|
||||
/device_discovery_resolution_code_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
function claimInput() {
|
||||
return {
|
||||
projectRef,
|
||||
enrollmentIntentRef,
|
||||
discoveryRef,
|
||||
deviceKey: "pilot-device",
|
||||
displayName: "Pilot device",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
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 sourceProjectId = "11111111-1111-4111-8111-111111111111";
|
||||
const targetProjectId = "22222222-2222-4222-8222-222222222222";
|
||||
const sourceOwnerId = "33333333-3333-4333-8333-333333333333";
|
||||
const targetOwnerId = "44444444-4444-4444-8444-444444444444";
|
||||
const enrollmentId = "55555555-5555-4555-8555-555555555555";
|
||||
const discoveryId = "66666666-6666-4666-8666-666666666666";
|
||||
const deviceId = "77777777-7777-4777-8777-777777777777";
|
||||
const routeId = "88888888-8888-4888-8888-888888888888";
|
||||
const identifierDigest = `hmac-sha256:${"a".repeat(64)}`;
|
||||
|
||||
test("claims only matching observed enrollment evidence into direct ownership", async () => {
|
||||
const actor = managementActor("member");
|
||||
const command = normalizeDeviceManagementCommand("device.claim", {
|
||||
projectRef: `project:${sourceProjectId}`,
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollmentId}`,
|
||||
discoveryRef: `discovery:${discoveryId}`,
|
||||
deviceKey: "pilot-device",
|
||||
displayName: "Pilot device",
|
||||
});
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-claim"),
|
||||
projectStep(sourceProjectId, sourceOwnerId),
|
||||
grantsStep(actor, "engineer"),
|
||||
step("from device_enrollment_intents", {
|
||||
rows: [enrollmentRow()],
|
||||
}),
|
||||
step("from device_discoveries", {
|
||||
rows: [discoveryRow()],
|
||||
}),
|
||||
step("insert into device_instances", {
|
||||
rows: [deviceRow({
|
||||
owner_scope_id: sourceOwnerId,
|
||||
project_id: sourceProjectId,
|
||||
device_key: command.deviceKey,
|
||||
display_name: command.displayName,
|
||||
})],
|
||||
}),
|
||||
step("update device_discoveries", { rows: [{ id: discoveryId }] }),
|
||||
step("update device_enrollment_intents", { rows: [{ id: enrollmentId }] }),
|
||||
step("insert into device_ownership_transitions"),
|
||||
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.claim",
|
||||
command,
|
||||
digestCharacter: "b",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.device.projectRef, `project:${sourceProjectId}`);
|
||||
assert.equal(result.result.device.identifier.masked, "********0001");
|
||||
assert.equal(JSON.stringify(result.result).includes(identifierDigest), false);
|
||||
assertSafeProjection(result.result);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("transfer requires explicit authority in the target project", async () => {
|
||||
const actor = managementActor("owner");
|
||||
const command = transferCommand();
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-transfer-denied"),
|
||||
step("from device_instances", { rows: [deviceRow()] }),
|
||||
projectStep(sourceProjectId, sourceOwnerId),
|
||||
grantsStep(actor, "owner"),
|
||||
projectStep(targetProjectId, targetOwnerId),
|
||||
step("from device_project_grants", { rows: [] }),
|
||||
step("rollback"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
await assert.rejects(
|
||||
repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "device.transfer",
|
||||
command,
|
||||
digestCharacter: "c",
|
||||
})),
|
||||
/device_project_capability_denied/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("authorized transfer preserves history and detaches source collections", async () => {
|
||||
const actor = managementActor("owner");
|
||||
const command = transferCommand();
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-transfer"),
|
||||
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("delete from device_collection_members", { rows: [], rowCount: 2 }),
|
||||
step("update device_instances", {
|
||||
rows: [deviceRow({
|
||||
owner_scope_id: targetOwnerId,
|
||||
project_id: targetProjectId,
|
||||
device_key: command.targetDeviceKey,
|
||||
})],
|
||||
}),
|
||||
step("insert into device_ownership_transitions"),
|
||||
step("insert into device_audit_events"),
|
||||
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.transfer",
|
||||
command,
|
||||
digestCharacter: "d",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.transferred, true);
|
||||
assert.equal(result.result.device.projectRef, `project:${targetProjectId}`);
|
||||
assert.equal(result.result.detachedCollectionCount, 2);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("reject resolves both quarantine and enrollment without exposing a digest", async () => {
|
||||
const actor = managementActor("member");
|
||||
const command = normalizeDeviceManagementCommand("discovery.reject", {
|
||||
projectRef: `project:${sourceProjectId}`,
|
||||
discoveryRef: `discovery:${discoveryId}`,
|
||||
resolutionCode: "operator.identity_mismatch",
|
||||
});
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-reject"),
|
||||
projectStep(sourceProjectId, sourceOwnerId),
|
||||
grantsStep(actor, "engineer"),
|
||||
step("from device_discoveries", { rows: [discoveryRow()] }),
|
||||
step("from device_enrollment_intents", { rows: [enrollmentRow()] }),
|
||||
step("update device_discoveries"),
|
||||
step("update device_enrollment_intents"),
|
||||
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: "discovery.reject",
|
||||
command,
|
||||
digestCharacter: "e",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.discovery.lifecycleState, "rejected");
|
||||
assert.equal(JSON.stringify(result.result).includes(identifierDigest), false);
|
||||
assertSafeProjection(result.result);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
function transferCommand() {
|
||||
return normalizeDeviceManagementCommand("device.transfer", {
|
||||
deviceRef: `device:${deviceId}`,
|
||||
sourceProjectRef: `project:${sourceProjectId}`,
|
||||
targetProjectRef: `project:${targetProjectId}`,
|
||||
targetDeviceKey: "transferred-device",
|
||||
});
|
||||
}
|
||||
|
||||
function enrollmentRow() {
|
||||
return {
|
||||
id: enrollmentId,
|
||||
project_id: sourceProjectId,
|
||||
route_id: routeId,
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
expected_identifier_kind: "serial",
|
||||
expected_identifier_digest: identifierDigest,
|
||||
expected_identifier_masked: "********0001",
|
||||
lifecycle_state: "observed",
|
||||
observed_discovery_id: discoveryId,
|
||||
claimed_device_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function discoveryRow() {
|
||||
return {
|
||||
id: discoveryId,
|
||||
project_id: sourceProjectId,
|
||||
route_id: routeId,
|
||||
enrollment_intent_id: enrollmentId,
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
identifier_kind: "serial",
|
||||
identifier_digest: identifierDigest,
|
||||
identifier_masked: "********0001",
|
||||
lifecycle_state: "quarantine",
|
||||
claimed_device_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides = {}) {
|
||||
return {
|
||||
id: deviceId,
|
||||
contour_id: null,
|
||||
owner_scope_id: sourceOwnerId,
|
||||
project_id: sourceProjectId,
|
||||
device_key: "pilot-device",
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
display_name: "Pilot device",
|
||||
identifier_kind: "serial",
|
||||
identifier_masked: "********0001",
|
||||
lifecycle_state: "claimed",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function managementActor(hubRole) {
|
||||
return normalizeManagementActor({
|
||||
userRef: "user:lifecycle-operator",
|
||||
hubRole,
|
||||
groupRefs: [],
|
||||
ownerScopes: [],
|
||||
});
|
||||
}
|
||||
|
||||
function projectStep(projectId, ownerScopeId) {
|
||||
return step("from device_projects p", {
|
||||
rows: [{
|
||||
id: projectId,
|
||||
owner_scope_id: ownerScopeId,
|
||||
lifecycle_state: "active",
|
||||
scope_kind: "company",
|
||||
owner_ref: `client:${ownerScopeId}`,
|
||||
owner_display_name: "Example Company",
|
||||
owner_lifecycle_state: "active",
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function grantsStep(actor, projectRole) {
|
||||
return step("from device_project_grants", {
|
||||
rows: [{
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
principal_kind: "user",
|
||||
principal_ref: actor.userRef,
|
||||
project_role: projectRole,
|
||||
capability_allow: [],
|
||||
capability_deny: [],
|
||||
lifecycle_state: "active",
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
released: false,
|
||||
async query(sql) {
|
||||
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