@nacre.work/api 0.1.0 → 0.3.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.
- package/dist/adapters.d.ts +133 -12
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +307 -52
- package/dist/adapters.js.map +1 -1
- package/dist/auth.d.ts +16 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +26 -0
- package/dist/auth.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +10 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +72 -25
- package/dist/init.js.map +1 -1
- package/dist/login.d.ts +13 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +6 -1
- package/dist/login.js.map +1 -1
- package/dist/main.js +102 -45
- package/dist/main.js.map +1 -1
- package/dist/pagination.d.ts +26 -2
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +11 -2
- package/dist/pagination.js.map +1 -1
- package/dist/server.d.ts +118 -37
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +388 -22
- package/dist/server.js.map +1 -1
- package/dist/service-keys.d.ts.map +1 -1
- package/dist/service-keys.js +7 -2
- package/dist/service-keys.js.map +1 -1
- package/package.json +2 -2
package/dist/adapters.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildFilter, cachedEffectivePrincipals, documentKey, effectivePrincipals, fromStateJson, loadGrants, loadGroupsVersion, loadScopeTree, PostgresGroupGraph, referenceAllows, reindexProgress, activeResolver, admitIngest, toStateJson, VectorStore, vectorName, withOrg, } from '@nacre.work/core';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { encodeCursor, pageOf } from './pagination.js';
|
|
4
4
|
import { applyRanking } from './rerank.js';
|
|
@@ -6,6 +6,8 @@ export class PostgresDocuments {
|
|
|
6
6
|
pool;
|
|
7
7
|
payload;
|
|
8
8
|
role;
|
|
9
|
+
principalsCache;
|
|
10
|
+
presign;
|
|
9
11
|
constructor(pool,
|
|
10
12
|
/**
|
|
11
13
|
* What writes metadata into the payload of a document's points.
|
|
@@ -14,10 +16,22 @@ export class PostgresDocuments {
|
|
|
14
16
|
* has no business searching, and a port that could would let a later change
|
|
15
17
|
* reach the index without a plan.
|
|
16
18
|
*/
|
|
17
|
-
payload, role
|
|
19
|
+
payload, role,
|
|
20
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
21
|
+
principalsCache,
|
|
22
|
+
/**
|
|
23
|
+
* Where a `source_url` comes from, when a deployment has object storage.
|
|
24
|
+
*
|
|
25
|
+
* Absent leaves the field off the response entirely, which is what every
|
|
26
|
+
* deployment without a bucket should see: a document whose bytes are in
|
|
27
|
+
* `documents.source_ref` has nothing to link to.
|
|
28
|
+
*/
|
|
29
|
+
presign) {
|
|
18
30
|
this.pool = pool;
|
|
19
31
|
this.payload = payload;
|
|
20
32
|
this.role = role;
|
|
33
|
+
this.principalsCache = principalsCache;
|
|
34
|
+
this.presign = presign;
|
|
21
35
|
}
|
|
22
36
|
/**
|
|
23
37
|
* Undefined for absent, for another organization's, and for one this caller
|
|
@@ -39,11 +53,11 @@ export class PostgresDocuments {
|
|
|
39
53
|
// The plan first. Reading the row and then checking is the same
|
|
40
54
|
// information leak with extra steps if the check is ever forgotten,
|
|
41
55
|
// and the layer a document sits in is what the grant is on.
|
|
42
|
-
const plan = resolve(await contextFor(client, auth), 'read');
|
|
56
|
+
const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'read');
|
|
43
57
|
if (plan.kind === 'none')
|
|
44
58
|
return undefined;
|
|
45
59
|
const { rows } = await client.query(`SELECT d.id, d.title, d.layer_id, l.slug AS layer, d.status, d.chunk_count,
|
|
46
|
-
d.updated_at, d.metadata
|
|
60
|
+
d.updated_at, d.metadata, d.source_type, d.source_ref
|
|
47
61
|
FROM documents d
|
|
48
62
|
JOIN layers l ON l.id = d.layer_id AND l.org_id = d.org_id
|
|
49
63
|
WHERE d.org_id = $1 AND d.id = $2 AND d.deleted_at IS NULL`, [orgId, documentId]);
|
|
@@ -71,6 +85,17 @@ export class PostgresDocuments {
|
|
|
71
85
|
// tag has no way to tell a successful write from a dropped one — and
|
|
72
86
|
// this field was dropped by the handler for as long as it existed.
|
|
73
87
|
metadata: (row.metadata ?? {}),
|
|
88
|
+
// Minted here and nowhere earlier: everything above this line is the
|
|
89
|
+
// permission check, so a link exists only for a caller who has just
|
|
90
|
+
// been found to hold `read` on this document. `write` alone does not
|
|
91
|
+
// reach here — rule 6 — and neither does a denied document.
|
|
92
|
+
//
|
|
93
|
+
// Only for `s3`. An `inline` document has no object, and a `url` one
|
|
94
|
+
// is somebody else's address that this system has no business
|
|
95
|
+
// signing.
|
|
96
|
+
...(this.presign !== undefined && row.source_type === 's3' && row.source_ref !== null
|
|
97
|
+
? { source_url: this.presign.url(row.source_ref) }
|
|
98
|
+
: {}),
|
|
74
99
|
};
|
|
75
100
|
}, this.role === undefined ? {} : { role: this.role });
|
|
76
101
|
}
|
|
@@ -102,7 +127,7 @@ export class PostgresDocuments {
|
|
|
102
127
|
const collection = orgs[0]?.vector_collection;
|
|
103
128
|
if (collection === undefined)
|
|
104
129
|
return false;
|
|
105
|
-
const plan = resolve(await contextFor(client, auth), 'write');
|
|
130
|
+
const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'write');
|
|
106
131
|
if (plan.kind === 'none')
|
|
107
132
|
return false;
|
|
108
133
|
const { rows } = await client.query(`SELECT layer_id FROM documents
|
|
@@ -146,11 +171,10 @@ export class NacreSearchService {
|
|
|
146
171
|
const collection = orgs[0]?.vector_collection;
|
|
147
172
|
if (collection === undefined)
|
|
148
173
|
return undefined;
|
|
149
|
-
const
|
|
150
|
-
const principals = effectivePrincipals(auth.principal, graph);
|
|
174
|
+
const principals = await principalsFor(client, auth, this.deps.principalsCache);
|
|
151
175
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
152
176
|
const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
|
|
153
|
-
const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'read');
|
|
177
|
+
const plan = activeResolver().resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'read');
|
|
154
178
|
// Every live layer with the model it is on. Small — an installation
|
|
155
179
|
// runs tens of layers per organization, not thousands — and it is what
|
|
156
180
|
// decides how many branches the query has.
|
|
@@ -495,6 +519,10 @@ export class PostgresAudit {
|
|
|
495
519
|
event.requestId,
|
|
496
520
|
]);
|
|
497
521
|
}, this.role === undefined ? {} : { role: this.role });
|
|
522
|
+
// Forwarding to a module's sinks is deliberately *not* here. It is
|
|
523
|
+
// `withAuditSinks`, applied to whatever port a surface was handed, because
|
|
524
|
+
// a sink wants every recorded event and not every event this adapter
|
|
525
|
+
// happened to record.
|
|
498
526
|
}
|
|
499
527
|
}
|
|
500
528
|
/**
|
|
@@ -569,7 +597,6 @@ export class HttpEmbedder {
|
|
|
569
597
|
});
|
|
570
598
|
}
|
|
571
599
|
}
|
|
572
|
-
export { aclTags };
|
|
573
600
|
export class NacreIngest {
|
|
574
601
|
deps;
|
|
575
602
|
constructor(deps) {
|
|
@@ -583,11 +610,10 @@ export class NacreIngest {
|
|
|
583
610
|
}
|
|
584
611
|
/** The layers this caller may write to, by slug. */
|
|
585
612
|
async writableLayer(client, auth, layerSlug) {
|
|
586
|
-
const
|
|
587
|
-
const principals = effectivePrincipals(auth.principal, graph);
|
|
613
|
+
const principals = await principalsFor(client, auth, this.deps.principalsCache);
|
|
588
614
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
589
615
|
const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
|
|
590
|
-
const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
|
|
616
|
+
const plan = activeResolver().resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
|
|
591
617
|
if (plan.kind === 'none')
|
|
592
618
|
return undefined;
|
|
593
619
|
const { rows } = await client.query(`SELECT id FROM layers WHERE org_id = $1 AND slug = $2 AND deleted_at IS NULL`, [auth.orgId, layerSlug]);
|
|
@@ -605,9 +631,46 @@ export class NacreIngest {
|
|
|
605
631
|
const layerId = await this.writableLayer(client, auth, request.layer);
|
|
606
632
|
if (layerId === undefined)
|
|
607
633
|
return undefined;
|
|
608
|
-
const
|
|
609
|
-
const
|
|
634
|
+
const inline = request.content !== undefined;
|
|
635
|
+
const source = inline ? request.content : request.url;
|
|
610
636
|
const hash = createHash('sha256').update(source, 'utf8').digest('hex');
|
|
637
|
+
// A module's ingest gate, after write is established and before anything
|
|
638
|
+
// is stored, so a refusal leaves neither a row nor an object behind. No
|
|
639
|
+
// gate registered is the open core admitting every document a caller may
|
|
640
|
+
// write, which is what it did before this point existed. A refusal
|
|
641
|
+
// becomes the handler's 4xx; it is not `undefined`, because that would
|
|
642
|
+
// read as "unwritable" and hide the layer the caller just wrote against.
|
|
643
|
+
const refusal = await admitIngest({
|
|
644
|
+
orgId: auth.orgId,
|
|
645
|
+
layerId,
|
|
646
|
+
principal: auth.principal,
|
|
647
|
+
role: auth.role,
|
|
648
|
+
externalId: request.externalId,
|
|
649
|
+
bytes: new TextEncoder().encode(source).byteLength,
|
|
650
|
+
});
|
|
651
|
+
if (refusal !== undefined) {
|
|
652
|
+
return { refused: true, status: refusal.status, reason: refusal.reason };
|
|
653
|
+
}
|
|
654
|
+
// Bytes to object storage before the row, never after.
|
|
655
|
+
//
|
|
656
|
+
// The reverse fails unrecoverably, which is the same argument the
|
|
657
|
+
// delete path is ordered by: a row that names an object which was never
|
|
658
|
+
// written is a document the worker fails on every attempt, forever,
|
|
659
|
+
// with the API having answered `queued`. A PUT with no row is a stray
|
|
660
|
+
// object at a deterministic key that the next ingest overwrites.
|
|
661
|
+
//
|
|
662
|
+
// Only for inline content. A `url` document is a reference already, and
|
|
663
|
+
// copying somebody else's URL into our bucket at ingest time would be
|
|
664
|
+
// a fetch on the request path — which is exactly what the worker does
|
|
665
|
+
// later, with a timeout and a sandbox.
|
|
666
|
+
let sourceType = inline ? 'inline' : 'url';
|
|
667
|
+
let sourceRef = source;
|
|
668
|
+
if (inline && this.deps.objects !== undefined) {
|
|
669
|
+
const key = documentKey(auth.orgId, layerId, request.externalId);
|
|
670
|
+
await this.deps.objects.put(key, new TextEncoder().encode(source), 'text/plain');
|
|
671
|
+
sourceType = 's3';
|
|
672
|
+
sourceRef = key;
|
|
673
|
+
}
|
|
611
674
|
const { rows } = await client.query(`INSERT INTO documents
|
|
612
675
|
(org_id, layer_id, external_id, source_type, source_ref, title, content_hash, metadata, status)
|
|
613
676
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending')
|
|
@@ -640,7 +703,7 @@ export class NacreIngest {
|
|
|
640
703
|
layerId,
|
|
641
704
|
request.externalId,
|
|
642
705
|
sourceType,
|
|
643
|
-
|
|
706
|
+
sourceRef,
|
|
644
707
|
request.title ?? null,
|
|
645
708
|
`sha256:${hash}`,
|
|
646
709
|
JSON.stringify(request.metadata),
|
|
@@ -667,11 +730,10 @@ export class NacreIngest {
|
|
|
667
730
|
const collection = orgs[0]?.vector_collection;
|
|
668
731
|
if (collection === undefined)
|
|
669
732
|
return false;
|
|
670
|
-
const
|
|
671
|
-
const principals = effectivePrincipals(auth.principal, graph);
|
|
733
|
+
const principals = await principalsFor(client, auth, this.deps.principalsCache);
|
|
672
734
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
673
735
|
const tree = await loadScopeTree(client, auth.orgId, [documentId]);
|
|
674
|
-
const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
|
|
736
|
+
const plan = activeResolver().resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
|
|
675
737
|
if (plan.kind === 'none')
|
|
676
738
|
return false;
|
|
677
739
|
const { rows } = await client.query(`SELECT layer_id FROM documents
|
|
@@ -727,9 +789,13 @@ export class NacreIngest {
|
|
|
727
789
|
export class PostgresJobs {
|
|
728
790
|
pool;
|
|
729
791
|
role;
|
|
730
|
-
|
|
792
|
+
principalsCache;
|
|
793
|
+
constructor(pool, role,
|
|
794
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
795
|
+
principalsCache) {
|
|
731
796
|
this.pool = pool;
|
|
732
797
|
this.role = role;
|
|
798
|
+
this.principalsCache = principalsCache;
|
|
733
799
|
}
|
|
734
800
|
async read(auth, jobId) {
|
|
735
801
|
const orgId = auth.orgId;
|
|
@@ -740,7 +806,7 @@ export class PostgresJobs {
|
|
|
740
806
|
// different projection — and needs the same check. The status and the
|
|
741
807
|
// error string are both facts about a document the caller may not be
|
|
742
808
|
// allowed to know exists.
|
|
743
|
-
const plan = resolve(await contextFor(client, auth), 'read');
|
|
809
|
+
const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'read');
|
|
744
810
|
if (plan.kind === 'none')
|
|
745
811
|
return undefined;
|
|
746
812
|
const { rows } = await client.query(`SELECT id, status, error, chunk_count, layer_id FROM documents
|
|
@@ -773,10 +839,24 @@ export class PostgresJobs {
|
|
|
773
839
|
}, this.role === undefined ? {} : { role: this.role });
|
|
774
840
|
}
|
|
775
841
|
}
|
|
842
|
+
async function principalsFor(client, auth, cache) {
|
|
843
|
+
if (cache === undefined) {
|
|
844
|
+
return effectivePrincipals(auth.principal, await PostgresGroupGraph.load(client, auth.orgId));
|
|
845
|
+
}
|
|
846
|
+
// In the same transaction as the grants that follow, so the key and the grant
|
|
847
|
+
// set describe one instant. Read separately, there is a window where the
|
|
848
|
+
// version says "fresh" about a set loaded before a change.
|
|
849
|
+
const groupsVersion = await loadGroupsVersion(client, auth.orgId);
|
|
850
|
+
return cachedEffectivePrincipals({
|
|
851
|
+
orgId: auth.orgId,
|
|
852
|
+
principal: auth.principal,
|
|
853
|
+
groupsVersion,
|
|
854
|
+
ttlSeconds: cache.ttlSeconds,
|
|
855
|
+
}, cache.store, () => PostgresGroupGraph.load(client, auth.orgId));
|
|
856
|
+
}
|
|
776
857
|
/** The per-request permission context, loaded once and asked several questions. */
|
|
777
|
-
async function contextFor(client, auth) {
|
|
778
|
-
const
|
|
779
|
-
const principals = effectivePrincipals(auth.principal, graph);
|
|
858
|
+
async function contextFor(client, auth, cache) {
|
|
859
|
+
const principals = await principalsFor(client, auth, cache);
|
|
780
860
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
781
861
|
const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
|
|
782
862
|
return { orgId: auth.orgId, role: auth.role, principals, grants, tree };
|
|
@@ -785,6 +865,7 @@ export class PostgresLayers {
|
|
|
785
865
|
pool;
|
|
786
866
|
vectors;
|
|
787
867
|
role;
|
|
868
|
+
principalsCache;
|
|
788
869
|
constructor(pool,
|
|
789
870
|
/**
|
|
790
871
|
* Only to ask which named vectors the organization's collection has.
|
|
@@ -797,10 +878,13 @@ export class PostgresLayers {
|
|
|
797
878
|
* answered 202 and the row says `pending`. Checking here is what turns that
|
|
798
879
|
* into a collection rebuild instead.
|
|
799
880
|
*/
|
|
800
|
-
vectors, role
|
|
881
|
+
vectors, role,
|
|
882
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
883
|
+
principalsCache) {
|
|
801
884
|
this.pool = pool;
|
|
802
885
|
this.vectors = vectors;
|
|
803
886
|
this.role = role;
|
|
887
|
+
this.principalsCache = principalsCache;
|
|
804
888
|
}
|
|
805
889
|
get scope() {
|
|
806
890
|
return this.role === undefined ? {} : { role: this.role };
|
|
@@ -815,7 +899,7 @@ export class PostgresLayers {
|
|
|
815
899
|
*/
|
|
816
900
|
async list(auth, page) {
|
|
817
901
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
818
|
-
const plan = resolve(await contextFor(client, auth), 'read');
|
|
902
|
+
const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'read');
|
|
819
903
|
if (plan.kind === 'none')
|
|
820
904
|
return { items: [], nextCursor: null };
|
|
821
905
|
// The count comes from the same statement. It is what a catalog is
|
|
@@ -829,7 +913,12 @@ export class PostgresLayers {
|
|
|
829
913
|
// grants exist — a commercial module — this becomes a number that
|
|
830
914
|
// discloses more than the caller can reach, and it has to be recomputed
|
|
831
915
|
// per caller or dropped.
|
|
916
|
+
// `created_at::text` beside `created_at`, and the cursor is built from
|
|
917
|
+
// it. See `Position.createdAt` — a timestamp that has been through a
|
|
918
|
+
// JavaScript `Date` is truncated to milliseconds, and a truncated bound
|
|
919
|
+
// matches the row it came from all over again.
|
|
832
920
|
const projection = `l.id, l.slug, l.name, l.workspace_id, l.description, l.created_at,
|
|
921
|
+
l.created_at::text AS created_at_text,
|
|
833
922
|
(SELECT count(*) FROM documents d
|
|
834
923
|
WHERE d.layer_id = l.id AND d.deleted_at IS NULL) AS document_count`;
|
|
835
924
|
// Ordered by (created_at, id) rather than by slug, because that is the
|
|
@@ -857,17 +946,20 @@ export class PostgresLayers {
|
|
|
857
946
|
documentCount: Number(r.document_count),
|
|
858
947
|
createdAt: r.created_at.toISOString(),
|
|
859
948
|
}));
|
|
860
|
-
return pageOf(layers, page, (l) => ({
|
|
949
|
+
return pageOf(layers, page, (l, i) => ({
|
|
950
|
+
createdAt: rows[i].created_at_text,
|
|
951
|
+
id: l.id,
|
|
952
|
+
}));
|
|
861
953
|
}, this.scope);
|
|
862
954
|
}
|
|
863
955
|
async update(auth, layerId, input) {
|
|
864
|
-
return updateLayer(this.pool, auth, layerId, input, this.role);
|
|
956
|
+
return updateLayer(this.pool, auth, layerId, input, this.role, this.principalsCache);
|
|
865
957
|
}
|
|
866
958
|
async create(auth, input) {
|
|
867
959
|
if (!/^[0-9a-f-]{36}$/i.test(input.workspaceId))
|
|
868
960
|
return { kind: 'denied' };
|
|
869
961
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
870
|
-
const context = await contextFor(client, auth);
|
|
962
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
871
963
|
// `referenceAllows` and not `resolve`: the question is "may they
|
|
872
964
|
// administer this one workspace", which is what the reference answers
|
|
873
965
|
// directly. Reading it out of a flattened plan would mean inferring a
|
|
@@ -994,9 +1086,13 @@ export class PostgresLayers {
|
|
|
994
1086
|
export class PostgresWorkspaces {
|
|
995
1087
|
pool;
|
|
996
1088
|
role;
|
|
997
|
-
|
|
1089
|
+
principalsCache;
|
|
1090
|
+
constructor(pool, role,
|
|
1091
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
1092
|
+
principalsCache) {
|
|
998
1093
|
this.pool = pool;
|
|
999
1094
|
this.role = role;
|
|
1095
|
+
this.principalsCache = principalsCache;
|
|
1000
1096
|
}
|
|
1001
1097
|
get scope() {
|
|
1002
1098
|
return this.role === undefined ? {} : { role: this.role };
|
|
@@ -1022,7 +1118,7 @@ export class PostgresWorkspaces {
|
|
|
1022
1118
|
*/
|
|
1023
1119
|
async list(auth, page) {
|
|
1024
1120
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1025
|
-
const context = await contextFor(client, auth);
|
|
1121
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
1026
1122
|
// No early return on `plan.kind === 'none'`, and that is the whole
|
|
1027
1123
|
// point of this endpoint.
|
|
1028
1124
|
//
|
|
@@ -1033,13 +1129,28 @@ export class PostgresWorkspaces {
|
|
|
1033
1129
|
// administer, and therefore unable to create the first layer in it,
|
|
1034
1130
|
// which is the deadlock this endpoint was added to break. It was
|
|
1035
1131
|
// written that way first and caught by running it.
|
|
1036
|
-
const plan = resolve(context, 'read');
|
|
1037
|
-
|
|
1132
|
+
const plan = activeResolver().resolve(context, 'read');
|
|
1133
|
+
// Seek and limit in SQL.
|
|
1134
|
+
//
|
|
1135
|
+
// Neither was here: the statement fetched every workspace in the
|
|
1136
|
+
// organization, ignored `page.after` entirely, and handed the whole
|
|
1137
|
+
// list to `pageOf` — which then answered "there is another page"
|
|
1138
|
+
// because the list was at least as long as the limit. So
|
|
1139
|
+
// `GET /v1/workspaces` returned the same complete list on every page
|
|
1140
|
+
// and never terminated, and a client following `next_cursor` looped
|
|
1141
|
+
// forever. Found by a test that walked the listing one item at a time.
|
|
1142
|
+
const after = page?.after;
|
|
1143
|
+
const seek = after === undefined ? '' : ' AND (w.created_at, w.id) > ($2::timestamptz, $3::uuid)';
|
|
1144
|
+
// One extra, because the filter below removes rows the caller may not
|
|
1145
|
+
// reach: without it a page whose last row is filtered out would report
|
|
1146
|
+
// itself as the end of the collection.
|
|
1147
|
+
const cap = page === undefined ? '' : ` LIMIT ${page.limit + 1}`;
|
|
1148
|
+
const { rows } = await client.query(`SELECT w.id, w.slug, w.name, w.created_at, w.created_at::text AS created_at_text,
|
|
1038
1149
|
(SELECT array_agg(l.id) FROM layers l
|
|
1039
1150
|
WHERE l.workspace_id = w.id AND l.deleted_at IS NULL) AS layer_ids
|
|
1040
1151
|
FROM workspaces w
|
|
1041
|
-
WHERE w.org_id = $1 AND w.deleted_at IS NULL
|
|
1042
|
-
ORDER BY w.created_at, w.id`, [auth.orgId]);
|
|
1152
|
+
WHERE w.org_id = $1 AND w.deleted_at IS NULL${seek}
|
|
1153
|
+
ORDER BY w.created_at, w.id${cap}`, after === undefined ? [auth.orgId] : [auth.orgId, after.createdAt, after.id]);
|
|
1043
1154
|
const reachable = rows.filter((w) => {
|
|
1044
1155
|
if (plan.kind === 'all')
|
|
1045
1156
|
return true;
|
|
@@ -1052,14 +1163,28 @@ export class PostgresWorkspaces {
|
|
|
1052
1163
|
// for a workspace with nothing in it yet.
|
|
1053
1164
|
return referenceAllows(context, { type: 'workspace', id: w.id }, 'read');
|
|
1054
1165
|
});
|
|
1055
|
-
const
|
|
1166
|
+
const fetched = page === undefined ? rows : rows.slice(0, page.limit);
|
|
1167
|
+
const items = reachable
|
|
1168
|
+
.filter((w) => fetched.some((f) => f.id === w.id))
|
|
1169
|
+
.map((w) => ({
|
|
1056
1170
|
id: w.id,
|
|
1057
1171
|
slug: w.slug,
|
|
1058
1172
|
name: w.name,
|
|
1059
1173
|
layerCount: (w.layer_ids ?? []).length,
|
|
1060
1174
|
createdAt: w.created_at.toISOString(),
|
|
1061
1175
|
}));
|
|
1062
|
-
|
|
1176
|
+
// From the last row **fetched**, not the last returned — the same rule
|
|
1177
|
+
// the grant listing follows and for the same reason: the filter above
|
|
1178
|
+
// removes rows the caller may not reach, and a cursor taken from a
|
|
1179
|
+
// survivor would skip everything the filter dropped after it.
|
|
1180
|
+
//
|
|
1181
|
+
// Which makes a short page normal here rather than a signal, and
|
|
1182
|
+
// `next_cursor` the only thing that says whether more exist.
|
|
1183
|
+
const lastFetched = fetched[fetched.length - 1];
|
|
1184
|
+
const nextCursor = page !== undefined && rows.length > page.limit && lastFetched !== undefined
|
|
1185
|
+
? encodeCursor({ createdAt: lastFetched.created_at_text, id: lastFetched.id })
|
|
1186
|
+
: null;
|
|
1187
|
+
return { items, nextCursor };
|
|
1063
1188
|
}, this.scope);
|
|
1064
1189
|
}
|
|
1065
1190
|
/**
|
|
@@ -1114,13 +1239,13 @@ export class PostgresWorkspaces {
|
|
|
1114
1239
|
* description is built from, so an agent's picture of what a layer holds
|
|
1115
1240
|
* changes with it. That is the reason it is editable at all.
|
|
1116
1241
|
*/
|
|
1117
|
-
async function updateLayer(pool, auth, layerId, input, role) {
|
|
1242
|
+
async function updateLayer(pool, auth, layerId, input, role, principalsCache) {
|
|
1118
1243
|
if (!/^[0-9a-f-]{36}$/i.test(layerId))
|
|
1119
1244
|
return false;
|
|
1120
1245
|
if (input.name === undefined && input.description === undefined)
|
|
1121
1246
|
return false;
|
|
1122
1247
|
return withOrg(pool, auth.orgId, async (client) => {
|
|
1123
|
-
const context = await contextFor(client, auth);
|
|
1248
|
+
const context = await contextFor(client, auth, principalsCache);
|
|
1124
1249
|
// The layer's workspace, before anything is decided — the grant is on the
|
|
1125
1250
|
// workspace, so there is nothing to ask until we know which one.
|
|
1126
1251
|
const { rows } = await client.query(`SELECT workspace_id FROM layers
|
|
@@ -1147,9 +1272,13 @@ async function updateLayer(pool, auth, layerId, input, role) {
|
|
|
1147
1272
|
export class PostgresGrants {
|
|
1148
1273
|
pool;
|
|
1149
1274
|
role;
|
|
1150
|
-
|
|
1275
|
+
principalsCache;
|
|
1276
|
+
constructor(pool, role,
|
|
1277
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
1278
|
+
principalsCache) {
|
|
1151
1279
|
this.pool = pool;
|
|
1152
1280
|
this.role = role;
|
|
1281
|
+
this.principalsCache = principalsCache;
|
|
1153
1282
|
}
|
|
1154
1283
|
get scope() {
|
|
1155
1284
|
return this.role === undefined ? {} : { role: this.role };
|
|
@@ -1177,15 +1306,15 @@ export class PostgresGrants {
|
|
|
1177
1306
|
*/
|
|
1178
1307
|
async list(auth, page) {
|
|
1179
1308
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1180
|
-
const context = await contextFor(client, auth);
|
|
1181
|
-
const plan = resolve(context, 'admin');
|
|
1309
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
1310
|
+
const plan = activeResolver().resolve(context, 'admin');
|
|
1182
1311
|
if (plan.kind === 'none')
|
|
1183
1312
|
return { items: [], nextCursor: null };
|
|
1184
1313
|
const after = page?.after;
|
|
1185
1314
|
const seek = after === undefined ? '' : ' AND (created_at, id) > ($2::timestamptz, $3::uuid)';
|
|
1186
1315
|
const cap = page === undefined ? '' : ` LIMIT ${page.limit}`;
|
|
1187
1316
|
const { rows } = await client.query(`SELECT id, principal_type, principal_id, scope_type, scope_id, permission, effect, source,
|
|
1188
|
-
created_at
|
|
1317
|
+
created_at, created_at::text AS created_at_text
|
|
1189
1318
|
FROM grants WHERE org_id = $1${seek} ORDER BY created_at, id${cap}`, after === undefined ? [auth.orgId] : [auth.orgId, after.createdAt, after.id]);
|
|
1190
1319
|
const visible = rows
|
|
1191
1320
|
.filter((r) => referenceAllows(context, { type: r.scope_type, id: r.scope_id }, 'admin'))
|
|
@@ -1205,7 +1334,7 @@ export class PostgresGrants {
|
|
|
1205
1334
|
// it — a page of grants silently missing from an administrator's view.
|
|
1206
1335
|
const lastFetched = rows[rows.length - 1];
|
|
1207
1336
|
const nextCursor = page !== undefined && rows.length >= page.limit && lastFetched !== undefined
|
|
1208
|
-
? encodeCursor({ createdAt: lastFetched.
|
|
1337
|
+
? encodeCursor({ createdAt: lastFetched.created_at_text, id: lastFetched.id })
|
|
1209
1338
|
: null;
|
|
1210
1339
|
return { items: visible, nextCursor };
|
|
1211
1340
|
}, this.scope);
|
|
@@ -1215,12 +1344,39 @@ export class PostgresGrants {
|
|
|
1215
1344
|
return undefined;
|
|
1216
1345
|
}
|
|
1217
1346
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1218
|
-
const context = await contextFor(client, auth);
|
|
1347
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
1219
1348
|
// Admin on the scope being granted, not admin in general. Otherwise
|
|
1220
1349
|
// anyone holding admin on one layer could grant themselves another.
|
|
1221
1350
|
if (!referenceAllows(context, { type: input.scopeType, id: input.scopeId }, 'admin')) {
|
|
1222
1351
|
return undefined;
|
|
1223
1352
|
}
|
|
1353
|
+
// And the scope has to exist in this organization, which the check
|
|
1354
|
+
// above does not establish. `referenceAllows` returns `true` for
|
|
1355
|
+
// `org_admin` on its third line — rule 3, admin on every scope
|
|
1356
|
+
// implicitly — and it returns *before* the scope is placed, so an
|
|
1357
|
+
// administrator naming any uuid at all passed.
|
|
1358
|
+
//
|
|
1359
|
+
// Not a leak, and the reason is worth stating rather than assumed: a
|
|
1360
|
+
// grant on a scope that is not here becomes `should: layer_id = X`
|
|
1361
|
+
// beside an unconditional `must: org_id = <this tenant>`, and points
|
|
1362
|
+
// carrying layer X belong to another tenant and live in another
|
|
1363
|
+
// collection. The second line of defence holds. What got written was a
|
|
1364
|
+
// row that permits nothing and points at nothing.
|
|
1365
|
+
//
|
|
1366
|
+
// It still has to be refused. An administrator reading `/v1/grants`
|
|
1367
|
+
// sees a grant they cannot explain on an id they cannot look up, and
|
|
1368
|
+
// `404` stops meaning what invariant 4 says it means — "no permission"
|
|
1369
|
+
// and "no such object" are one answer, which requires that naming a
|
|
1370
|
+
// nonexistent object *reaches* that answer rather than succeeding.
|
|
1371
|
+
//
|
|
1372
|
+
// Written out per table rather than assembled, so the table name can
|
|
1373
|
+
// never come from a caller-supplied string. `scopeType` is a checked
|
|
1374
|
+
// union already; this makes it structural.
|
|
1375
|
+
const exists = await client.query(input.scopeType === 'workspace'
|
|
1376
|
+
? `SELECT 1 FROM workspaces WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`
|
|
1377
|
+
: `SELECT 1 FROM layers WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, input.scopeId]);
|
|
1378
|
+
if (exists.rowCount !== 1)
|
|
1379
|
+
return undefined;
|
|
1224
1380
|
const { rows } = await client.query(`INSERT INTO grants
|
|
1225
1381
|
(org_id, principal_type, principal_id, scope_type, scope_id, permission, effect, source)
|
|
1226
1382
|
VALUES ($1,$2,$3,$4,$5,$6,'allow','api')
|
|
@@ -1274,7 +1430,7 @@ export class PostgresGrants {
|
|
|
1274
1430
|
// because the object is a grant rather than a document.
|
|
1275
1431
|
if (row === undefined)
|
|
1276
1432
|
return false;
|
|
1277
|
-
const context = await contextFor(client, auth);
|
|
1433
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
1278
1434
|
const scope = {
|
|
1279
1435
|
type: row.scope_type,
|
|
1280
1436
|
id: row.scope_id,
|
|
@@ -1318,9 +1474,13 @@ export class PostgresGrants {
|
|
|
1318
1474
|
export class PostgresAuditReader {
|
|
1319
1475
|
pool;
|
|
1320
1476
|
role;
|
|
1321
|
-
|
|
1477
|
+
principalsCache;
|
|
1478
|
+
constructor(pool, role,
|
|
1479
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
1480
|
+
principalsCache) {
|
|
1322
1481
|
this.pool = pool;
|
|
1323
1482
|
this.role = role;
|
|
1483
|
+
this.principalsCache = principalsCache;
|
|
1324
1484
|
}
|
|
1325
1485
|
/**
|
|
1326
1486
|
* Actions that record a substantive access to a document's contents.
|
|
@@ -1370,7 +1530,8 @@ export class PostgresAuditReader {
|
|
|
1370
1530
|
// without a second query and without a count — a count over a
|
|
1371
1531
|
// retention window is the expensive thing this endpoint must not do on
|
|
1372
1532
|
// every request.
|
|
1373
|
-
const { rows } = await client.query(`SELECT id::text, occurred_at,
|
|
1533
|
+
const { rows } = await client.query(`SELECT id::text, occurred_at, occurred_at::text AS occurred_at_text,
|
|
1534
|
+
actor_type, actor_id, actor_label, action,
|
|
1374
1535
|
surface, client, target, result, detail, request_id
|
|
1375
1536
|
FROM audit_events
|
|
1376
1537
|
WHERE ${where.join(' AND ')}
|
|
@@ -1390,9 +1551,19 @@ export class PostgresAuditReader {
|
|
|
1390
1551
|
detail: r.detail ?? {},
|
|
1391
1552
|
requestId: r.request_id,
|
|
1392
1553
|
}));
|
|
1554
|
+
// From the row rather than from the mapped record, because the record
|
|
1555
|
+
// carries an ISO string and this cursor needs full precision.
|
|
1556
|
+
//
|
|
1557
|
+
// This one **skips** rather than repeats, and that is the same bug seen
|
|
1558
|
+
// from the other side: the ordering is descending, so a bound truncated
|
|
1559
|
+
// downwards excludes the row it came from *and* everything between the
|
|
1560
|
+
// truncated value and the real one. On a listing that would lose a page
|
|
1561
|
+
// of layers; on the access log it silently loses events, in the one
|
|
1562
|
+
// place the product promises a precise answer.
|
|
1563
|
+
const lastRow = rows[Math.min(page.limit, rows.length) - 1];
|
|
1393
1564
|
const last = items[items.length - 1];
|
|
1394
|
-
const nextCursor = rows.length > page.limit && last !== undefined
|
|
1395
|
-
? encodeCursor({ createdAt:
|
|
1565
|
+
const nextCursor = rows.length > page.limit && last !== undefined && lastRow !== undefined
|
|
1566
|
+
? encodeCursor({ createdAt: lastRow.occurred_at_text, id: last.id })
|
|
1396
1567
|
: null;
|
|
1397
1568
|
return { items, nextCursor };
|
|
1398
1569
|
}, this.scopeFor());
|
|
@@ -1426,10 +1597,14 @@ export class PostgresReindex {
|
|
|
1426
1597
|
pool;
|
|
1427
1598
|
vectors;
|
|
1428
1599
|
role;
|
|
1429
|
-
|
|
1600
|
+
principalsCache;
|
|
1601
|
+
constructor(pool, vectors, role,
|
|
1602
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
1603
|
+
principalsCache) {
|
|
1430
1604
|
this.pool = pool;
|
|
1431
1605
|
this.vectors = vectors;
|
|
1432
1606
|
this.role = role;
|
|
1607
|
+
this.principalsCache = principalsCache;
|
|
1433
1608
|
}
|
|
1434
1609
|
get scope() {
|
|
1435
1610
|
return this.role === undefined ? {} : { role: this.role };
|
|
@@ -1440,7 +1615,7 @@ export class PostgresReindex {
|
|
|
1440
1615
|
// vector in the layer and changes which model answers every future
|
|
1441
1616
|
// query — `read` is nowhere near enough, and `write` is about putting
|
|
1442
1617
|
// documents in rather than about the layer itself.
|
|
1443
|
-
const plan = resolve(await contextFor(client, auth), 'admin');
|
|
1618
|
+
const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'admin');
|
|
1444
1619
|
if (plan.kind === 'none')
|
|
1445
1620
|
return undefined;
|
|
1446
1621
|
// Locked for the whole decision. Two starts arriving together would
|
|
@@ -1555,7 +1730,7 @@ export class PostgresReindex {
|
|
|
1555
1730
|
// being migrated is not an administrative fact — it explains why a
|
|
1556
1731
|
// result set moved — and hiding it from someone who can already read
|
|
1557
1732
|
// the layer buys nothing.
|
|
1558
|
-
const plan = resolve(await contextFor(client, auth), 'read');
|
|
1733
|
+
const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'read');
|
|
1559
1734
|
if (plan.kind === 'none')
|
|
1560
1735
|
return undefined;
|
|
1561
1736
|
const { rows } = await client.query(`SELECT id, vector_name, reindex_state FROM layers
|
|
@@ -1606,7 +1781,87 @@ export class PostgresReindex {
|
|
|
1606
1781
|
failed: state.failed,
|
|
1607
1782
|
progress: reindexProgress(live),
|
|
1608
1783
|
error: state.error ?? null,
|
|
1784
|
+
check: state.check ?? null,
|
|
1609
1785
|
};
|
|
1610
1786
|
}
|
|
1611
1787
|
}
|
|
1788
|
+
/**
|
|
1789
|
+
* The reference query set behind the reindex recall gate.
|
|
1790
|
+
*
|
|
1791
|
+
* `admin` on the layer for both operations, and that is a decision rather than
|
|
1792
|
+
* a copy of the reindex adapter above. Reading the set reveals which documents
|
|
1793
|
+
* an operator considers the canonical answers to a query, which is a statement
|
|
1794
|
+
* about the layer's contents; rule 7 makes `admin` the permission that implies
|
|
1795
|
+
* being allowed to see them, and rule 6 means `write` does not.
|
|
1796
|
+
*/
|
|
1797
|
+
export class PostgresReferenceQueries {
|
|
1798
|
+
pool;
|
|
1799
|
+
role;
|
|
1800
|
+
principalsCache;
|
|
1801
|
+
constructor(pool, role, principalsCache) {
|
|
1802
|
+
this.pool = pool;
|
|
1803
|
+
this.role = role;
|
|
1804
|
+
this.principalsCache = principalsCache;
|
|
1805
|
+
}
|
|
1806
|
+
get scope() {
|
|
1807
|
+
return this.role === undefined ? {} : { role: this.role };
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* The layer, if this caller may administer it.
|
|
1811
|
+
*
|
|
1812
|
+
* `undefined` for a layer that is not there and for one they may not touch —
|
|
1813
|
+
* one answer, which the handler turns into `404`. Deleted layers are not
|
|
1814
|
+
* there: a reference set on a deleted layer describes nothing.
|
|
1815
|
+
*/
|
|
1816
|
+
async administrable(client, auth, layerId) {
|
|
1817
|
+
const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'admin');
|
|
1818
|
+
if (plan.kind === 'none')
|
|
1819
|
+
return false;
|
|
1820
|
+
if (plan.kind === 'scoped' && !plan.layers.includes(layerId))
|
|
1821
|
+
return false;
|
|
1822
|
+
const { rows } = await client.query('SELECT id FROM layers WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL', [auth.orgId, layerId]);
|
|
1823
|
+
return rows.length > 0;
|
|
1824
|
+
}
|
|
1825
|
+
async list(auth, layerId) {
|
|
1826
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1827
|
+
if (!(await this.administrable(client, auth, layerId)))
|
|
1828
|
+
return undefined;
|
|
1829
|
+
const { rows } = await client.query(`SELECT id, query, expected FROM reference_queries
|
|
1830
|
+
WHERE org_id = $1 AND layer_id = $2 ORDER BY ordinal`, [auth.orgId, layerId]);
|
|
1831
|
+
return rows.map((r) => ({ id: r.id, query: r.query, expected: r.expected }));
|
|
1832
|
+
}, this.scope);
|
|
1833
|
+
}
|
|
1834
|
+
async replace(auth, layerId, queries) {
|
|
1835
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1836
|
+
if (!(await this.administrable(client, auth, layerId)))
|
|
1837
|
+
return undefined;
|
|
1838
|
+
// Delete then insert, in the transaction `withOrg` already opened. A
|
|
1839
|
+
// set is one statement about the layer, so there is no moment at which
|
|
1840
|
+
// half of the old one and half of the new one are both in the table —
|
|
1841
|
+
// which matters because the worker reads this to decide whether a layer
|
|
1842
|
+
// has a gate at all.
|
|
1843
|
+
await client.query('DELETE FROM reference_queries WHERE org_id = $1 AND layer_id = $2', [
|
|
1844
|
+
auth.orgId,
|
|
1845
|
+
layerId,
|
|
1846
|
+
]);
|
|
1847
|
+
const inserted = [];
|
|
1848
|
+
for (const [ordinal, q] of queries.entries()) {
|
|
1849
|
+
const { rows } = await client.query(`INSERT INTO reference_queries (org_id, layer_id, query, expected, ordinal)
|
|
1850
|
+
VALUES ($1, $2, $3, $4, $5) RETURNING id`, [auth.orgId, layerId, q.query, [...q.expected], ordinal]);
|
|
1851
|
+
inserted.push({
|
|
1852
|
+
id: rows[0]?.id,
|
|
1853
|
+
query: q.query,
|
|
1854
|
+
expected: [...q.expected],
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
// The external ids are deliberately **not** validated against
|
|
1858
|
+
// `documents` here. A reference set is often written before the
|
|
1859
|
+
// documents it names are ingested, and refusing it then would make the
|
|
1860
|
+
// gate impossible to set up on a new layer. The check resolves them
|
|
1861
|
+
// when it runs, and an entry that resolves to nothing fails the reindex
|
|
1862
|
+
// by name — which is the moment it actually matters.
|
|
1863
|
+
return inserted;
|
|
1864
|
+
}, this.scope);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1612
1867
|
//# sourceMappingURL=adapters.js.map
|