feat(device-core): add project ownership schema
This commit is contained in:
@@ -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 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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user