diff --git a/device-plane/services/device-control-core/migrations/002_device_project_access.sql b/device-plane/services/device-control-core/migrations/002_device_project_access.sql new file mode 100644 index 0000000..216beb0 --- /dev/null +++ b/device-plane/services/device-control-core/migrations/002_device_project_access.sql @@ -0,0 +1,113 @@ +begin; + +create table if not exists device_owner_scopes ( + id uuid primary key, + scope_kind text not null + check (scope_kind in ('company', 'personal')), + owner_ref text not null + check (length(btrim(owner_ref)) between 3 and 256), + display_name text not null + check (length(btrim(display_name)) between 1 and 160), + lifecycle_state text not null default 'active' + check (lifecycle_state in ('active', 'suspended', 'retired')), + created_by_ref text not null + check (length(btrim(created_by_ref)) between 3 and 256), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (scope_kind, owner_ref) +); + +create table if not exists device_projects ( + id uuid primary key, + owner_scope_id uuid not null references device_owner_scopes(id), + project_key text not null + check (project_key ~ '^[a-z][a-z0-9-]{1,62}$'), + name text not null + check (length(btrim(name)) between 1 and 160), + description text, + lifecycle_state text not null default 'active' + check (lifecycle_state in ('active', 'suspended', 'archived')), + created_by_ref text not null + check (length(btrim(created_by_ref)) between 3 and 256), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (owner_scope_id, project_key) +); + +create index if not exists device_projects_owner_scope_idx + on device_projects (owner_scope_id, lifecycle_state, updated_at desc); + +alter table device_instances + add column if not exists project_id uuid references device_projects(id); + +create unique index if not exists device_instances_id_project_idx + on device_instances (id, project_id); + +create table if not exists device_collections ( + id uuid primary key, + project_id uuid not null references device_projects(id), + collection_key text not null + check (collection_key ~ '^[a-z][a-z0-9-]{1,62}$'), + name text not null + check (length(btrim(name)) between 1 and 160), + description text, + lifecycle_state text not null default 'active' + check (lifecycle_state in ('active', 'archived')), + created_by_ref text not null + check (length(btrim(created_by_ref)) between 3 and 256), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (project_id, collection_key), + unique (id, project_id) +); + +create index if not exists device_collections_project_idx + on device_collections (project_id, lifecycle_state, updated_at desc); + +create table if not exists device_collection_members ( + collection_id uuid not null, + device_id uuid not null, + project_id uuid not null references device_projects(id), + added_by_ref text not null + check (length(btrim(added_by_ref)) between 3 and 256), + added_at timestamptz not null default now(), + primary key (collection_id, device_id), + foreign key (collection_id, project_id) + references device_collections(id, project_id), + foreign key (device_id, project_id) + references device_instances(id, project_id) +); + +create index if not exists device_collection_members_device_idx + on device_collection_members (device_id, collection_id); + +create table if not exists device_project_grants ( + id uuid primary key, + project_id uuid not null references device_projects(id), + principal_kind text not null + check (principal_kind in ('user', 'group')), + principal_ref text not null + check (length(btrim(principal_ref)) between 3 and 256), + project_role text not null + check (project_role in ('viewer', 'operator', 'engineer', 'admin', 'owner')), + capability_allow text[] not null default '{}', + capability_deny text[] not null default '{}', + lifecycle_state text not null default 'active' + check (lifecycle_state in ('active', 'revoked')), + created_by_ref text not null + check (length(btrim(created_by_ref)) between 3 and 256), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (project_id, principal_kind, principal_ref), + check (not (capability_allow && capability_deny)) +); + +create index if not exists device_project_grants_principal_idx + on device_project_grants ( + principal_kind, + principal_ref, + lifecycle_state, + project_id + ); + +commit; diff --git a/device-plane/services/device-control-core/src/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs index 36639cc..2a457e5 100644 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -9,6 +9,10 @@ import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter const { Pool } = pg; const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const migrationFiles = [ + "001_device_plane_foundation.sql", + "002_device_project_access.sql", +]; export class PostgresDeviceRepository { constructor({ databaseUrl, poolSize = 10 } = {}) { @@ -22,11 +26,13 @@ export class PostgresDeviceRepository { } async migrate() { - const sql = await readFile( - resolve(serviceRoot, "migrations/001_device_plane_foundation.sql"), - "utf8", - ); - await this.pool.query(sql); + for (const migrationFile of migrationFiles) { + const sql = await readFile( + resolve(serviceRoot, "migrations", migrationFile), + "utf8", + ); + await this.pool.query(sql); + } await this.pool.query( `insert into device_model_profiles ( profile_ref, diff --git a/device-plane/services/device-control-core/test/project-access-migration.test.mjs b/device-plane/services/device-control-core/test/project-access-migration.test.mjs new file mode 100644 index 0000000..180020c --- /dev/null +++ b/device-plane/services/device-control-core/test/project-access-migration.test.mjs @@ -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/002_device_project_access.sql", + import.meta.url, +); +const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); + +test("project access migration defines owner, project, collection and grant boundaries", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + for (const table of [ + "device_owner_scopes", + "device_projects", + "device_collections", + "device_collection_members", + "device_project_grants", + ]) { + assert.match(sql, new RegExp(`create table if not exists ${table}`)); + } + + assert.match(sql, /scope_kind in \('company', 'personal'\)/); + assert.match(sql, /principal_kind in \('user', 'group'\)/); + assert.match( + sql, + /project_role in \('viewer', 'operator', 'engineer', 'admin', 'owner'\)/, + ); + assert.match(sql, /unique \(scope_kind, owner_ref\)/); + assert.match(sql, /unique \(owner_scope_id, project_key\)/); + assert.match(sql, /unique \(project_id, principal_kind, principal_ref\)/); + assert.match(sql, /not \(capability_allow && capability_deny\)/); + assert.match( + sql, + /foreign key \(collection_id, project_id\)\s+references device_collections\(id, project_id\)/, + ); + assert.match( + sql, + /foreign key \(device_id, project_id\)\s+references device_instances\(id, project_id\)/, + ); +}); + +test("project access migration contains no tenant, device or credential seed", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.doesNotMatch(sql, /insert\s+into/i); + assert.doesNotMatch(sql, /dcctouch|arusnavi|b2|imei/i); + assert.doesNotMatch(sql, /password|secret|token|credential_ref/i); +}); + +test("repository applies project access migration after the foundation", async () => { + const source = await readFile(repositoryUrl, "utf8"); + const foundationIndex = source.indexOf("001_device_plane_foundation.sql"); + const projectAccessIndex = source.indexOf("002_device_project_access.sql"); + + assert.notEqual(foundationIndex, -1); + assert.notEqual(projectAccessIndex, -1); + assert.ok(foundationIndex < projectAccessIndex); +});