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 { 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user