@nacre.work/core 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +3 -0
  2. package/dist/authz/cache.d.ts +54 -0
  3. package/dist/authz/cache.d.ts.map +1 -0
  4. package/dist/authz/cache.js +63 -0
  5. package/dist/authz/cache.js.map +1 -0
  6. package/dist/authz/filter.d.ts +60 -0
  7. package/dist/authz/filter.d.ts.map +1 -0
  8. package/dist/authz/filter.js +54 -0
  9. package/dist/authz/filter.js.map +1 -0
  10. package/dist/authz/index.d.ts +15 -0
  11. package/dist/authz/index.d.ts.map +1 -0
  12. package/dist/authz/index.js +9 -0
  13. package/dist/authz/index.js.map +1 -0
  14. package/dist/authz/permissions.d.ts +20 -0
  15. package/dist/authz/permissions.d.ts.map +1 -0
  16. package/dist/authz/permissions.js +43 -0
  17. package/dist/authz/permissions.js.map +1 -0
  18. package/dist/authz/principals.d.ts +34 -0
  19. package/dist/authz/principals.d.ts.map +1 -0
  20. package/dist/authz/principals.js +42 -0
  21. package/dist/authz/principals.js.map +1 -0
  22. package/dist/authz/reference.d.ts +25 -0
  23. package/dist/authz/reference.d.ts.map +1 -0
  24. package/dist/authz/reference.js +34 -0
  25. package/dist/authz/reference.js.map +1 -0
  26. package/dist/authz/resolve.d.ts +45 -0
  27. package/dist/authz/resolve.d.ts.map +1 -0
  28. package/dist/authz/resolve.js +98 -0
  29. package/dist/authz/resolve.js.map +1 -0
  30. package/dist/authz/scope-tree.d.ts +52 -0
  31. package/dist/authz/scope-tree.d.ts.map +1 -0
  32. package/dist/authz/scope-tree.js +56 -0
  33. package/dist/authz/scope-tree.js.map +1 -0
  34. package/dist/authz/store.d.ts +50 -0
  35. package/dist/authz/store.d.ts.map +1 -0
  36. package/dist/authz/store.js +115 -0
  37. package/dist/authz/store.js.map +1 -0
  38. package/dist/authz/tags.d.ts +40 -0
  39. package/dist/authz/tags.d.ts.map +1 -0
  40. package/dist/authz/tags.js +48 -0
  41. package/dist/authz/tags.js.map +1 -0
  42. package/dist/config.d.ts +73 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +157 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/db/client.d.ts +38 -0
  47. package/dist/db/client.d.ts.map +1 -0
  48. package/dist/db/client.js +34 -0
  49. package/dist/db/client.js.map +1 -0
  50. package/dist/db/migrate.d.ts +32 -0
  51. package/dist/db/migrate.d.ts.map +1 -0
  52. package/dist/db/migrate.js +103 -0
  53. package/dist/db/migrate.js.map +1 -0
  54. package/dist/index.d.ts +16 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/metrics.d.ts +77 -0
  59. package/dist/metrics.d.ts.map +1 -0
  60. package/dist/metrics.js +179 -0
  61. package/dist/metrics.js.map +1 -0
  62. package/dist/migrate-main.d.ts +2 -0
  63. package/dist/migrate-main.d.ts.map +1 -0
  64. package/dist/migrate-main.js +34 -0
  65. package/dist/migrate-main.js.map +1 -0
  66. package/dist/migrations/0001_init.sql +226 -0
  67. package/dist/migrations/0002_rls_coverage.sql +128 -0
  68. package/dist/migrations/0003_groups_version.sql +48 -0
  69. package/dist/migrations/0004_acl_version.sql +26 -0
  70. package/dist/migrations/0005_grants_bump_version.sql +38 -0
  71. package/dist/migrations/0006_layer_slug_unique.sql +47 -0
  72. package/dist/migrations/0007_index_lease.sql +42 -0
  73. package/dist/observability.d.ts +11 -0
  74. package/dist/observability.d.ts.map +1 -0
  75. package/dist/observability.js +55 -0
  76. package/dist/observability.js.map +1 -0
  77. package/dist/tsconfig.tsbuildinfo +1 -0
  78. package/dist/types.d.ts +54 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +15 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/vector/query.d.ts +140 -0
  83. package/dist/vector/query.d.ts.map +1 -0
  84. package/dist/vector/query.js +94 -0
  85. package/dist/vector/query.js.map +1 -0
  86. package/dist/vector/search.d.ts +62 -0
  87. package/dist/vector/search.d.ts.map +1 -0
  88. package/dist/vector/search.js +98 -0
  89. package/dist/vector/search.js.map +1 -0
  90. package/package.json +39 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * A minimal Prometheus registry.
3
+ *
4
+ * Dependency-free because the exposition format is four lines of rules and this
5
+ * container reads documents for a living — every dependency in it is something
6
+ * to keep patched. If the needs here outgrow histograms and gauges, replace it
7
+ * with prom-client rather than growing this file.
8
+ */
9
+ const NAME = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
10
+ function escapeLabel(value) {
11
+ return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
12
+ }
13
+ function series(name, labels) {
14
+ const pairs = Object.entries(labels)
15
+ .filter(([, v]) => v !== '')
16
+ .sort(([a], [b]) => a.localeCompare(b))
17
+ .map(([k, v]) => `${k}="${escapeLabel(v)}"`);
18
+ return pairs.length === 0 ? name : `${name}{${pairs.join(',')}}`;
19
+ }
20
+ export class Counter {
21
+ name;
22
+ help;
23
+ type = 'counter';
24
+ #values = new Map();
25
+ constructor(name, help) {
26
+ this.name = name;
27
+ this.help = help;
28
+ if (!NAME.test(name))
29
+ throw new Error(`not a valid metric name: ${name}`);
30
+ }
31
+ inc(labels = {}, by = 1) {
32
+ const key = series(this.name, labels);
33
+ const entry = this.#values.get(key) ?? { labels, value: 0 };
34
+ entry.value += by;
35
+ this.#values.set(key, entry);
36
+ }
37
+ render() {
38
+ // A counter with no observations is still reported, at zero. An absent
39
+ // series and a zero series look the same on a graph and mean opposite
40
+ // things: "nothing happened" versus "the exporter is not running".
41
+ if (this.#values.size === 0)
42
+ return [`${this.name} 0`];
43
+ return [...this.#values].map(([key, e]) => `${key} ${e.value}`);
44
+ }
45
+ }
46
+ export class Gauge {
47
+ name;
48
+ help;
49
+ type = 'gauge';
50
+ #values = new Map();
51
+ constructor(name, help) {
52
+ this.name = name;
53
+ this.help = help;
54
+ if (!NAME.test(name))
55
+ throw new Error(`not a valid metric name: ${name}`);
56
+ }
57
+ set(value, labels = {}) {
58
+ this.#values.set(series(this.name, labels), value);
59
+ }
60
+ reset() {
61
+ this.#values.clear();
62
+ }
63
+ render() {
64
+ if (this.#values.size === 0)
65
+ return [`${this.name} 0`];
66
+ return [...this.#values].map(([key, v]) => `${key} ${v}`);
67
+ }
68
+ }
69
+ const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
70
+ export class Histogram {
71
+ name;
72
+ help;
73
+ buckets;
74
+ type = 'histogram';
75
+ #series = new Map();
76
+ constructor(name, help, buckets = DEFAULT_BUCKETS) {
77
+ this.name = name;
78
+ this.help = help;
79
+ this.buckets = buckets;
80
+ if (!NAME.test(name))
81
+ throw new Error(`not a valid metric name: ${name}`);
82
+ }
83
+ observe(seconds, labels = {}) {
84
+ const key = series(this.name, labels);
85
+ const entry = this.#series.get(key) ?? {
86
+ labels,
87
+ counts: new Array(this.buckets.length).fill(0),
88
+ sum: 0,
89
+ count: 0,
90
+ };
91
+ for (let i = 0; i < this.buckets.length; i++) {
92
+ if (seconds <= this.buckets[i])
93
+ entry.counts[i] = entry.counts[i] + 1;
94
+ }
95
+ entry.sum += seconds;
96
+ entry.count += 1;
97
+ this.#series.set(key, entry);
98
+ }
99
+ /** Times `fn`, recording however it ends. A failure has a duration too. */
100
+ async time(labels, fn) {
101
+ const started = performance.now();
102
+ try {
103
+ return await fn();
104
+ }
105
+ finally {
106
+ this.observe((performance.now() - started) / 1000, labels);
107
+ }
108
+ }
109
+ render() {
110
+ const lines = [];
111
+ for (const entry of this.#series.values()) {
112
+ // Already cumulative, and deliberately so: `observe` increments every
113
+ // bucket whose bound the value is under, rather than only its own. The
114
+ // exposition format requires cumulative counts, and summing them here
115
+ // instead would be the same arithmetic done once per scrape rather than
116
+ // once per observation.
117
+ for (let i = 0; i < this.buckets.length; i++) {
118
+ lines.push(`${series(`${this.name}_bucket`, { ...entry.labels, le: String(this.buckets[i]) })} ${entry.counts[i]}`);
119
+ }
120
+ lines.push(`${series(`${this.name}_bucket`, { ...entry.labels, le: '+Inf' })} ${entry.count}`);
121
+ lines.push(`${series(`${this.name}_sum`, entry.labels)} ${entry.sum}`);
122
+ lines.push(`${series(`${this.name}_count`, entry.labels)} ${entry.count}`);
123
+ }
124
+ return lines;
125
+ }
126
+ }
127
+ export class Registry {
128
+ #metrics = [];
129
+ #collectors = [];
130
+ register(metric) {
131
+ if (this.#metrics.some((m) => m.name === metric.name)) {
132
+ throw new Error(`metric already registered: ${metric.name}`);
133
+ }
134
+ this.#metrics.push(metric);
135
+ return metric;
136
+ }
137
+ /**
138
+ * A function run before every scrape, for values that are queries rather than
139
+ * counters — document counts, the propagation lag, pending tombstones.
140
+ */
141
+ collect(fn) {
142
+ this.#collectors.push(fn);
143
+ }
144
+ async render() {
145
+ // A collector that fails must not blank the whole exposition: the metrics
146
+ // that still work are how you find out which one broke.
147
+ await Promise.allSettled(this.#collectors.map((fn) => fn()));
148
+ const lines = [];
149
+ for (const metric of this.#metrics) {
150
+ lines.push(`# HELP ${metric.name} ${metric.help}`);
151
+ lines.push(`# TYPE ${metric.name} ${metric.type}`);
152
+ lines.push(...metric.render());
153
+ }
154
+ return `${lines.join('\n')}\n`;
155
+ }
156
+ }
157
+ /**
158
+ * The metrics docs/config.md requires.
159
+ *
160
+ * `nacre_acl_propagation_lag_seconds` is the one with an alert on it, and the
161
+ * reason is worth restating where the metric is defined: it is the only
162
+ * external evidence that invariant I4 — a revoked grant reflected within the
163
+ * SLA — still holds. Everything else here describes how the system is doing.
164
+ * This one describes whether it is still correct.
165
+ */
166
+ export function createMetrics(registry) {
167
+ return {
168
+ searchDuration: registry.register(new Histogram('nacre_search_duration_seconds', 'Search latency, end to end', [
169
+ 0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2, 5,
170
+ ])),
171
+ searchResults: registry.register(new Counter('nacre_search_results_total', 'Results returned, by layer')),
172
+ aclDenials: registry.register(new Counter('nacre_acl_denials_total', 'Access denials, by reason')),
173
+ aclPropagationLag: registry.register(new Gauge('nacre_acl_propagation_lag_seconds', 'Age in seconds of the oldest document, per organization, whose ACL tags predate the current groups_version. Alert on max() over this. The only external evidence that invariant I4 holds')),
174
+ ingestDuration: registry.register(new Histogram('nacre_ingest_duration_seconds', 'Indexing latency, by stage')),
175
+ documents: registry.register(new Gauge('nacre_documents_total', 'Documents, by organization and status')),
176
+ tombstonesPending: registry.register(new Gauge('nacre_tombstones_pending_total', 'Deleted documents whose vectors are not yet purged. Climbing means garbage collection is losing')),
177
+ };
178
+ }
179
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.js","sourceRoot":"","sources":["../metrics.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=migrate-main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate-main.d.ts","sourceRoot":"","sources":["../migrate-main.ts"],"names":[],"mappings":""}
@@ -0,0 +1,34 @@
1
+ import { migrate } from './db/migrate.js';
2
+ /**
3
+ * The migration entry point, for the container.
4
+ *
5
+ * `pnpm migrate` runs `scripts/migrate.ts` through tsx, which is a development
6
+ * dependency and is not in the runtime image — so a deployment had no way to
7
+ * apply the schema at all. Compose started the API against an empty database,
8
+ * every request failed on a missing table, and the first thing a self-hoster
9
+ * saw was `relation "organizations" does not exist`.
10
+ *
11
+ * A separate process rather than a step inside the API: several API replicas
12
+ * starting at once would each try to migrate, and the one that loses the race
13
+ * reports a failure that is not one. Compose runs this to completion first.
14
+ */
15
+ async function main() {
16
+ const url = process.env.NACRE_PG_URL;
17
+ if (url === undefined || url.length === 0) {
18
+ // Exit 2, like every other entry point refusing an incomplete environment.
19
+ console.error('NACRE_PG_URL is not set. Configuration is validated at startup and this is startup.');
20
+ process.exit(2);
21
+ }
22
+ const { applied, skipped } = await migrate(url);
23
+ console.log(JSON.stringify({
24
+ msg: 'migrations complete',
25
+ applied: applied.length,
26
+ skipped: skipped.length,
27
+ names: applied,
28
+ }));
29
+ }
30
+ main().catch((error) => {
31
+ console.error(JSON.stringify({ msg: 'migration failed', error: String(error) }));
32
+ process.exit(1);
33
+ });
34
+ //# sourceMappingURL=migrate-main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate-main.js","sourceRoot":"","sources":["../migrate-main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAEzC;;;;;;;;;;;;GAYG;AAEH,KAAK,UAAU,IAAI;IACjB,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAA;IACpC,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1C,2EAA2E;QAC3E,OAAO,CAAC,KAAK,CAAC,qFAAqF,CAAC,CAAA;QACpG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;IAED,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAA;IAE/C,OAAO,CAAC,GAAG,CACT,IAAI,CAAC,SAAS,CAAC;QACb,GAAG,EAAE,qBAAqB;QAC1B,OAAO,EAAE,OAAO,CAAC,MAAM;QACvB,OAAO,EAAE,OAAO,CAAC,MAAM;QACvB,KAAK,EAAE,OAAO;KACf,CAAC,CACH,CAAA;AACH,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAA;IAChF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CAAC,CAAA"}
@@ -0,0 +1,226 @@
1
+ -- 0001 — initial schema.
2
+ --
3
+ -- The source of truth for tenants, permissions, metadata, and audit. Vectors
4
+ -- live in Qdrant and originals in S3; this holds only what needs transactions.
5
+ --
6
+ -- Specification: docs/authz.md and docs/architecture.md.
7
+
8
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
9
+ CREATE EXTENSION IF NOT EXISTS citext;
10
+
11
+ -- ─────────────────────────── tenants ───────────────────────────
12
+
13
+ CREATE TABLE organizations (
14
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
15
+ slug citext UNIQUE NOT NULL,
16
+ name text NOT NULL,
17
+ vector_collection text NOT NULL, -- one Qdrant collection per organization
18
+ quotas jsonb NOT NULL DEFAULT '{}'::jsonb,
19
+ groups_version bigint NOT NULL DEFAULT 1, -- permission cache invalidation
20
+ created_at timestamptz NOT NULL DEFAULT now(),
21
+ deleted_at timestamptz
22
+ );
23
+
24
+ CREATE TABLE sso_configs (
25
+ org_id uuid PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
26
+ protocol text NOT NULL CHECK (protocol IN ('oidc','saml')),
27
+ issuer text NOT NULL,
28
+ metadata jsonb NOT NULL,
29
+ scim_enabled boolean NOT NULL DEFAULT false,
30
+ scim_token_hash text,
31
+ updated_at timestamptz NOT NULL DEFAULT now()
32
+ );
33
+
34
+ CREATE TABLE users (
35
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
36
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
37
+ external_id text, -- sub from the IdP
38
+ email citext NOT NULL,
39
+ role text NOT NULL DEFAULT 'member'
40
+ CHECK (role IN ('platform_admin','org_admin','workspace_admin','member')),
41
+ password_hash text, -- NULL when SSO-only
42
+ disabled_at timestamptz,
43
+ created_at timestamptz NOT NULL DEFAULT now(),
44
+ UNIQUE (org_id, email),
45
+ UNIQUE (org_id, external_id)
46
+ );
47
+
48
+ CREATE TABLE groups (
49
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
50
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
51
+ external_id text,
52
+ name text NOT NULL,
53
+ UNIQUE (org_id, name)
54
+ );
55
+
56
+ -- Nested groups. Cycles are the resolver's problem to terminate on, not the
57
+ -- schema's to prevent: see docs/authz.md, T14.
58
+ CREATE TABLE group_members (
59
+ group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
60
+ member_user uuid REFERENCES users(id) ON DELETE CASCADE,
61
+ member_group uuid REFERENCES groups(id) ON DELETE CASCADE,
62
+ CHECK (num_nonnulls(member_user, member_group) = 1),
63
+ UNIQUE (group_id, member_user, member_group)
64
+ );
65
+
66
+ CREATE TABLE service_accounts (
67
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
68
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
69
+ name text NOT NULL,
70
+ key_hash text NOT NULL,
71
+ key_prefix text NOT NULL, -- first 8 characters, for display only
72
+ last_used_at timestamptz,
73
+ revoked_at timestamptz,
74
+ created_at timestamptz NOT NULL DEFAULT now(),
75
+ UNIQUE (org_id, name)
76
+ );
77
+ CREATE INDEX ON service_accounts (key_prefix) WHERE revoked_at IS NULL;
78
+
79
+ -- ─────────────────────── data structure ───────────────────────
80
+
81
+ CREATE TABLE embedding_providers (
82
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
83
+ org_id uuid REFERENCES organizations(id) ON DELETE CASCADE, -- NULL = global default
84
+ name text NOT NULL,
85
+ endpoint text NOT NULL, -- OpenAI-compatible URL
86
+ model text NOT NULL,
87
+ dimensions int NOT NULL,
88
+ credentials_ref text, -- a reference into the secret store, never the secret
89
+ created_at timestamptz NOT NULL DEFAULT now()
90
+ );
91
+
92
+ CREATE TABLE workspaces (
93
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
94
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
95
+ slug citext NOT NULL,
96
+ name text NOT NULL,
97
+ created_at timestamptz NOT NULL DEFAULT now(),
98
+ deleted_at timestamptz,
99
+ UNIQUE (org_id, slug)
100
+ );
101
+
102
+ CREATE TABLE layers (
103
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
104
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
105
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
106
+ slug citext NOT NULL,
107
+ name text NOT NULL,
108
+ -- This text is what the MCP search tool's generated description is built
109
+ -- from, so it is user-facing copy, not an internal note.
110
+ description text NOT NULL DEFAULT '',
111
+ provider_id uuid NOT NULL REFERENCES embedding_providers(id),
112
+ vector_name text NOT NULL, -- named vector inside the collection
113
+ chunk_config jsonb NOT NULL DEFAULT '{"size":800,"overlap":120,"strategy":"recursive"}'::jsonb,
114
+ reindex_state jsonb, -- {"status":"running","shadow_vector":"v2",...}
115
+ created_at timestamptz NOT NULL DEFAULT now(),
116
+ deleted_at timestamptz,
117
+ UNIQUE (workspace_id, slug)
118
+ );
119
+
120
+ CREATE TABLE documents (
121
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
122
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
123
+ layer_id uuid NOT NULL REFERENCES layers(id) ON DELETE CASCADE,
124
+ external_id text, -- client-side idempotency key
125
+ source_type text NOT NULL CHECK (source_type IN ('s3','url','external_id','inline')),
126
+ source_ref text,
127
+ title text,
128
+ content_hash text NOT NULL,
129
+ version int NOT NULL DEFAULT 1,
130
+ status text NOT NULL DEFAULT 'pending'
131
+ CHECK (status IN ('pending','parsing','indexing','indexed','failed')),
132
+ error text,
133
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
134
+ chunk_count int NOT NULL DEFAULT 0,
135
+ deleted_at timestamptz, -- tombstone; GC clears the vectors
136
+ vectors_purged_at timestamptz,
137
+ created_at timestamptz NOT NULL DEFAULT now(),
138
+ updated_at timestamptz NOT NULL DEFAULT now(),
139
+ UNIQUE (layer_id, external_id)
140
+ );
141
+ CREATE INDEX ON documents (layer_id) WHERE deleted_at IS NULL;
142
+ CREATE INDEX ON documents (org_id, status);
143
+ CREATE INDEX ON documents (content_hash);
144
+ -- The garbage-collection queue.
145
+ CREATE INDEX ON documents (deleted_at) WHERE deleted_at IS NOT NULL AND vectors_purged_at IS NULL;
146
+
147
+ -- Chunks are kept for returning text and for tracing. The vector itself is in
148
+ -- Qdrant; this table never holds one.
149
+ CREATE TABLE chunks (
150
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
151
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
152
+ document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
153
+ ordinal int NOT NULL,
154
+ text text NOT NULL,
155
+ token_count int,
156
+ point_id uuid NOT NULL, -- point id in Qdrant
157
+ UNIQUE (document_id, ordinal)
158
+ );
159
+
160
+ -- ─────────────────────────── permissions ───────────────────────────
161
+
162
+ CREATE TABLE grants (
163
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
164
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
165
+ principal_type text NOT NULL CHECK (principal_type IN ('user','group','service_account')),
166
+ principal_id uuid NOT NULL,
167
+ scope_type text NOT NULL CHECK (scope_type IN ('workspace','layer','document')),
168
+ scope_id uuid NOT NULL,
169
+ permission text NOT NULL CHECK (permission IN ('read','write','admin')),
170
+ effect text NOT NULL DEFAULT 'allow' CHECK (effect IN ('allow','deny')),
171
+ source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual','scim','api')),
172
+ created_by uuid REFERENCES users(id),
173
+ created_at timestamptz NOT NULL DEFAULT now(),
174
+ UNIQUE (org_id, principal_type, principal_id, scope_type, scope_id, permission)
175
+ );
176
+ CREATE INDEX ON grants (org_id, principal_type, principal_id);
177
+ CREATE INDEX ON grants (org_id, scope_type, scope_id);
178
+
179
+ -- ─────────────────────────── audit ───────────────────────────
180
+ -- Event schema: docs/audit.md. Append-only — UPDATE and DELETE must be revoked
181
+ -- from the application role, not merely avoided by it.
182
+
183
+ CREATE TABLE audit_events (
184
+ id bigserial PRIMARY KEY,
185
+ org_id uuid NOT NULL,
186
+ occurred_at timestamptz NOT NULL DEFAULT now(),
187
+ actor_type text NOT NULL,
188
+ actor_id uuid,
189
+ actor_label text NOT NULL,
190
+ action text NOT NULL,
191
+ surface text NOT NULL CHECK (surface IN ('api','mcp','admin','system')),
192
+ client text, -- MCP client name from _meta
193
+ target jsonb NOT NULL,
194
+ result text NOT NULL CHECK (result IN ('allow','deny','error')),
195
+ detail jsonb NOT NULL DEFAULT '{}'::jsonb,
196
+ request_id text
197
+ );
198
+ CREATE INDEX ON audit_events (org_id, occurred_at DESC);
199
+ CREATE INDEX ON audit_events (org_id, actor_id, occurred_at DESC);
200
+ CREATE INDEX ON audit_events USING gin (target);
201
+
202
+ -- ─────────── second line of defense: RLS ───────────
203
+ --
204
+ -- The application already filters by org_id. RLS exists so that one forgotten
205
+ -- check is a query returning nothing rather than a cross-tenant leak. The
206
+ -- application role sets app.current_org on the connection.
207
+ --
208
+ -- This is defense in depth, not the mechanism. Invariant I1 is enforced in the
209
+ -- application and verified again at serialization; do not treat RLS as a
210
+ -- reason to skip either.
211
+
212
+ ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
213
+ ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
214
+ ALTER TABLE grants ENABLE ROW LEVEL SECURITY;
215
+ ALTER TABLE layers ENABLE ROW LEVEL SECURITY;
216
+ ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
217
+
218
+ CREATE POLICY org_isolation ON documents USING (org_id = current_setting('app.current_org')::uuid);
219
+ CREATE POLICY org_isolation ON chunks USING (org_id = current_setting('app.current_org')::uuid);
220
+ CREATE POLICY org_isolation ON grants USING (org_id = current_setting('app.current_org')::uuid);
221
+ CREATE POLICY org_isolation ON layers USING (org_id = current_setting('app.current_org')::uuid);
222
+ CREATE POLICY org_isolation ON workspaces USING (org_id = current_setting('app.current_org')::uuid);
223
+
224
+ -- Background worker jobs run under a separate role with BYPASSRLS and are
225
+ -- required to name org_id explicitly in every query. That role is the one place
226
+ -- the second line of defense is off, so it gets the most review.
@@ -0,0 +1,128 @@
1
+ -- 0002 — extend row-level security to the rest of the tenant tables.
2
+ --
3
+ -- 0001 enabled RLS on documents, chunks, grants, layers, and workspaces, and
4
+ -- stopped there. Six more tables carry org_id and had no policy: users, groups,
5
+ -- service_accounts, sso_configs, embedding_providers, and audit_events. The
6
+ -- application filters all of them, which is exactly the assumption RLS exists
7
+ -- to survive — one forgotten WHERE on `users` leaks a member list, and one on
8
+ -- `audit_events` leaks which documents another organization's staff opened.
9
+ --
10
+ -- group_members carried no org_id at all, so it could not be constrained even
11
+ -- in principle. It is the edge in the graph that effective_principals walks: a
12
+ -- row joining a group in one organization to a user in another would hand that
13
+ -- user every grant the group holds. Nothing writes such a row today, and
14
+ -- nothing structural stopped one.
15
+
16
+ -- ─────────────────── group_members needs a tenant column ───────────────────
17
+
18
+ ALTER TABLE group_members ADD COLUMN org_id uuid REFERENCES organizations(id) ON DELETE CASCADE;
19
+
20
+ UPDATE group_members gm SET org_id = g.org_id FROM groups g WHERE g.id = gm.group_id;
21
+
22
+ ALTER TABLE group_members ALTER COLUMN org_id SET NOT NULL;
23
+
24
+ -- Both endpoints must live in the org the row claims. A composite foreign key
25
+ -- is what makes that checkable by the database rather than by whoever writes
26
+ -- the insert.
27
+ ALTER TABLE groups ADD CONSTRAINT groups_id_org_key UNIQUE (id, org_id);
28
+ ALTER TABLE users ADD CONSTRAINT users_id_org_key UNIQUE (id, org_id);
29
+
30
+ ALTER TABLE group_members
31
+ ADD CONSTRAINT group_members_group_in_org
32
+ FOREIGN KEY (group_id, org_id) REFERENCES groups (id, org_id) ON DELETE CASCADE;
33
+
34
+ ALTER TABLE group_members
35
+ ADD CONSTRAINT group_members_user_in_org
36
+ FOREIGN KEY (member_user, org_id) REFERENCES users (id, org_id) ON DELETE CASCADE;
37
+
38
+ ALTER TABLE group_members
39
+ ADD CONSTRAINT group_members_nested_group_in_org
40
+ FOREIGN KEY (member_group, org_id) REFERENCES groups (id, org_id) ON DELETE CASCADE;
41
+
42
+ CREATE INDEX ON group_members (org_id, group_id);
43
+
44
+ -- ─────────────────────────── the remaining policies ───────────────────────────
45
+
46
+ ALTER TABLE users ENABLE ROW LEVEL SECURITY;
47
+ ALTER TABLE groups ENABLE ROW LEVEL SECURITY;
48
+ ALTER TABLE group_members ENABLE ROW LEVEL SECURITY;
49
+ ALTER TABLE service_accounts ENABLE ROW LEVEL SECURITY;
50
+ ALTER TABLE sso_configs ENABLE ROW LEVEL SECURITY;
51
+ ALTER TABLE audit_events ENABLE ROW LEVEL SECURITY;
52
+ ALTER TABLE embedding_providers ENABLE ROW LEVEL SECURITY;
53
+
54
+ CREATE POLICY org_isolation ON users USING (org_id = current_setting('app.current_org')::uuid);
55
+ CREATE POLICY org_isolation ON groups USING (org_id = current_setting('app.current_org')::uuid);
56
+ CREATE POLICY org_isolation ON group_members USING (org_id = current_setting('app.current_org')::uuid);
57
+ CREATE POLICY org_isolation ON service_accounts USING (org_id = current_setting('app.current_org')::uuid);
58
+ CREATE POLICY org_isolation ON sso_configs USING (org_id = current_setting('app.current_org')::uuid);
59
+ CREATE POLICY org_isolation ON audit_events USING (org_id = current_setting('app.current_org')::uuid);
60
+
61
+ -- embedding_providers is the one exception, and it is a real one: a row with a
62
+ -- NULL org_id is the installation-wide default model, readable by every
63
+ -- organization by design. The policy allows exactly that and nothing else — a
64
+ -- provider belonging to another tenant stays invisible, including the endpoint
65
+ -- and the credentials reference.
66
+ CREATE POLICY org_isolation ON embedding_providers
67
+ USING (org_id IS NULL OR org_id = current_setting('app.current_org')::uuid);
68
+
69
+ -- Writing a global default is not something a tenant may do.
70
+ CREATE POLICY org_writes_own ON embedding_providers
71
+ FOR INSERT WITH CHECK (org_id = current_setting('app.current_org')::uuid);
72
+
73
+ -- ─────────────────────── RLS that actually applies ───────────────────────
74
+ --
75
+ -- ENABLE ROW LEVEL SECURITY alone protects nothing from the role that owns the
76
+ -- tables, and nothing at all from a superuser. Migrations run as the owner, so
77
+ -- in any deployment where the application reuses that connection — which is
78
+ -- the default until somebody deliberately splits the roles — every policy in
79
+ -- 0001 was inert. It was verified inert: a connection scoped to org B could
80
+ -- read org A's documents, layers, and users.
81
+ --
82
+ -- FORCE makes the policies apply to the owner too. Superusers still bypass
83
+ -- them, which is why the application must not connect as one.
84
+
85
+ ALTER TABLE documents FORCE ROW LEVEL SECURITY;
86
+ ALTER TABLE chunks FORCE ROW LEVEL SECURITY;
87
+ ALTER TABLE grants FORCE ROW LEVEL SECURITY;
88
+ ALTER TABLE layers FORCE ROW LEVEL SECURITY;
89
+ ALTER TABLE workspaces FORCE ROW LEVEL SECURITY;
90
+ ALTER TABLE users FORCE ROW LEVEL SECURITY;
91
+ ALTER TABLE groups FORCE ROW LEVEL SECURITY;
92
+ ALTER TABLE group_members FORCE ROW LEVEL SECURITY;
93
+ ALTER TABLE service_accounts FORCE ROW LEVEL SECURITY;
94
+ ALTER TABLE sso_configs FORCE ROW LEVEL SECURITY;
95
+ ALTER TABLE audit_events FORCE ROW LEVEL SECURITY;
96
+ ALTER TABLE embedding_providers FORCE ROW LEVEL SECURITY;
97
+
98
+ -- ─────────────────────────── the audit log is append-only ───────────────────────────
99
+ --
100
+ -- docs/audit.md requires this at the database level rather than by convention.
101
+ -- The application role gets INSERT and SELECT; UPDATE and DELETE are not
102
+ -- granted, so a bug cannot rewrite history and neither can an attacker holding
103
+ -- the application's credentials.
104
+ --
105
+ -- The role is created here if it does not exist so that a fresh development
106
+ -- database behaves like production. Deployments that manage roles externally
107
+ -- can drop this block; the REVOKE below is the part that matters.
108
+
109
+ DO $$
110
+ BEGIN
111
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nacre_app') THEN
112
+ CREATE ROLE nacre_app NOLOGIN;
113
+ END IF;
114
+ END
115
+ $$;
116
+
117
+ GRANT SELECT, INSERT ON audit_events TO nacre_app;
118
+ REVOKE UPDATE, DELETE ON audit_events FROM nacre_app;
119
+ GRANT USAGE, SELECT ON SEQUENCE audit_events_id_seq TO nacre_app;
120
+
121
+ -- Everything else the application touches. audit_events is deliberately not in
122
+ -- this list; it was granted narrowly above and must stay that way.
123
+ GRANT USAGE ON SCHEMA public TO nacre_app;
124
+ GRANT SELECT, INSERT, UPDATE, DELETE ON
125
+ organizations, users, groups, group_members, service_accounts, sso_configs,
126
+ embedding_providers, workspaces, layers, documents, chunks, grants
127
+ TO nacre_app;
128
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO nacre_app;
@@ -0,0 +1,48 @@
1
+ -- 0003 — make groups_version move on its own.
2
+ --
3
+ -- 0001 gave organizations a groups_version and described it as "permission
4
+ -- cache invalidation", and nothing incremented it. A cache keyed on a value
5
+ -- that never changes is a cache that never invalidates, so every effective
6
+ -- principals entry would have gone stale on the first membership change and
7
+ -- stayed stale until its TTL expired — which is the difference between
8
+ -- invariant I4 holding by construction and holding if the clock cooperates.
9
+ --
10
+ -- Incrementing it in the database rather than in the application is the point.
11
+ -- Group membership is written by SCIM, by the admin UI, and by the API, and a
12
+ -- version bumped by whichever of those remembered would be a version that
13
+ -- lies. There is one writer here: Postgres.
14
+
15
+ CREATE OR REPLACE FUNCTION bump_groups_version() RETURNS trigger
16
+ LANGUAGE plpgsql AS $$
17
+ DECLARE
18
+ target uuid;
19
+ BEGIN
20
+ target := COALESCE(NEW.org_id, OLD.org_id);
21
+ UPDATE organizations SET groups_version = groups_version + 1 WHERE id = target;
22
+ RETURN NULL; -- AFTER trigger; the return value is ignored
23
+ END
24
+ $$;
25
+
26
+ -- Statement-level, not row-level: a SCIM sync replacing a hundred memberships
27
+ -- should move the version once, not a hundred times, and every reader only
28
+ -- cares that it moved.
29
+ CREATE TRIGGER group_members_bump_version
30
+ AFTER INSERT OR UPDATE OR DELETE ON group_members
31
+ FOR EACH ROW EXECUTE FUNCTION bump_groups_version();
32
+
33
+ -- Groups themselves matter too: deleting a group revokes everything it granted,
34
+ -- and the cascade to group_members does not necessarily fire a statement the
35
+ -- reader can see.
36
+ CREATE TRIGGER groups_bump_version
37
+ AFTER INSERT OR UPDATE OR DELETE ON groups
38
+ FOR EACH ROW EXECUTE FUNCTION bump_groups_version();
39
+
40
+ -- The function runs as its definer so it can update organizations, which the
41
+ -- application role can read but must not rewrite at will.
42
+ ALTER FUNCTION bump_groups_version() SECURITY DEFINER;
43
+ REVOKE ALL ON FUNCTION bump_groups_version() FROM PUBLIC;
44
+ GRANT EXECUTE ON FUNCTION bump_groups_version() TO nacre_app;
45
+
46
+ -- Reading the version is on the search path, so it gets an index rather than a
47
+ -- sequential scan of organizations on every query.
48
+ CREATE INDEX IF NOT EXISTS organizations_groups_version_idx ON organizations (id, groups_version);