feat(device-core): add project ownership schema

This commit is contained in:
Codex
2026-08-10 16:59:15 +03:00
parent bf0bc50abb
commit 336602c7ca
3 changed files with 184 additions and 5 deletions
@@ -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;
@@ -9,6 +9,10 @@ import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter
const { Pool } = pg; const { Pool } = pg;
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const migrationFiles = [
"001_device_plane_foundation.sql",
"002_device_project_access.sql",
];
export class PostgresDeviceRepository { export class PostgresDeviceRepository {
constructor({ databaseUrl, poolSize = 10 } = {}) { constructor({ databaseUrl, poolSize = 10 } = {}) {
@@ -22,11 +26,13 @@ export class PostgresDeviceRepository {
} }
async migrate() { async migrate() {
const sql = await readFile( for (const migrationFile of migrationFiles) {
resolve(serviceRoot, "migrations/001_device_plane_foundation.sql"), const sql = await readFile(
"utf8", resolve(serviceRoot, "migrations", migrationFile),
); "utf8",
await this.pool.query(sql); );
await this.pool.query(sql);
}
await this.pool.query( await this.pool.query(
`insert into device_model_profiles ( `insert into device_model_profiles (
profile_ref, profile_ref,
@@ -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);
});