@govcore/federation 0.2.1 → 0.2.2

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/src/links.ts DELETED
@@ -1,328 +0,0 @@
1
- // @govcore/federation/links — the cross-org content-link lifecycle.
2
- //
3
- // A cross-org link is an approved relationship between a content item in one org
4
- // and one in another. Entity ids carry no FK (they cross org boundaries) and the
5
- // link semantics are app-defined: `linkType`, `sourceEntityType`/`targetEntityType`
6
- // are plain strings here. The *source* org proposes a link; only the *target* org
7
- // approves or rejects it. Entity ownership and federated-visibility preconditions
8
- // are the caller's responsibility (they live over app-owned content) — this module
9
- // owns the link record and its transitions. Each transition is audited.
10
-
11
- import { and, eq, inArray, or } from 'drizzle-orm'
12
- import { crossOrgLinks, type GovcoreDb } from '@govcore/schema'
13
- import { writeAuditLog } from '@govcore/audit'
14
-
15
- export type CrossOrgLink = typeof crossOrgLinks.$inferSelect
16
-
17
- /** What to do with a link request given any existing link for the same pair. */
18
- export type LinkRequestAction = 'block' | 'reactivate' | 'create'
19
-
20
- /**
21
- * Pure: a pending/active link blocks a new request; a previously rejected one is
22
- * reactivated; otherwise a fresh link is created.
23
- */
24
- export function resolveLinkRequest(
25
- existing: Pick<CrossOrgLink, 'status'> | null | undefined,
26
- ): LinkRequestAction {
27
- if (!existing) return 'create'
28
- if (existing.status === 'pending' || existing.status === 'active') return 'block'
29
- return 'reactivate'
30
- }
31
-
32
- const touchesEntity = (entityType: string, entityId: string) =>
33
- or(
34
- and(eq(crossOrgLinks.sourceEntityType, entityType), eq(crossOrgLinks.sourceEntityId, entityId)),
35
- and(eq(crossOrgLinks.targetEntityType, entityType), eq(crossOrgLinks.targetEntityId, entityId)),
36
- )
37
-
38
- const betweenOrgs = (orgAId: string, orgBId: string) =>
39
- or(
40
- and(eq(crossOrgLinks.sourceOrgId, orgAId), eq(crossOrgLinks.targetOrgId, orgBId)),
41
- and(eq(crossOrgLinks.sourceOrgId, orgBId), eq(crossOrgLinks.targetOrgId, orgAId)),
42
- )
43
-
44
- /** All links where `orgId` is either the source or target org. */
45
- export async function getCrossOrgLinks(db: GovcoreDb, orgId: string): Promise<CrossOrgLink[]> {
46
- return db
47
- .select()
48
- .from(crossOrgLinks)
49
- .where(or(eq(crossOrgLinks.sourceOrgId, orgId), eq(crossOrgLinks.targetOrgId, orgId)))
50
- }
51
-
52
- /** All links touching a given entity (on either side). */
53
- export async function getLinksForEntity(
54
- db: GovcoreDb,
55
- entityType: string,
56
- entityId: string,
57
- ): Promise<CrossOrgLink[]> {
58
- return db.select().from(crossOrgLinks).where(touchesEntity(entityType, entityId))
59
- }
60
-
61
- export interface LinkEndpoints {
62
- sourceEntityType: string
63
- sourceEntityId: string
64
- targetEntityType: string
65
- targetEntityId: string
66
- }
67
-
68
- /** The existing link for an exact source→target entity pair, or null. */
69
- export async function findCrossOrgLink(
70
- db: GovcoreDb,
71
- ends: LinkEndpoints,
72
- ): Promise<CrossOrgLink | null> {
73
- const [row] = await db
74
- .select()
75
- .from(crossOrgLinks)
76
- .where(
77
- and(
78
- eq(crossOrgLinks.sourceEntityType, ends.sourceEntityType),
79
- eq(crossOrgLinks.sourceEntityId, ends.sourceEntityId),
80
- eq(crossOrgLinks.targetEntityType, ends.targetEntityType),
81
- eq(crossOrgLinks.targetEntityId, ends.targetEntityId),
82
- ),
83
- )
84
- .limit(1)
85
- return row ?? null
86
- }
87
-
88
- export interface RequestCrossOrgLinkParams extends LinkEndpoints {
89
- sourceOrgId: string
90
- targetOrgId: string
91
- linkType: string
92
- actorUserId: string
93
- }
94
-
95
- /**
96
- * Propose a cross-org link. Blocks if a pending/active link for the pair already
97
- * exists; reactivates a previously rejected one; otherwise creates a pending
98
- * link. Audited. Entity ownership/visibility must be validated by the caller.
99
- */
100
- export async function requestCrossOrgLink(
101
- db: GovcoreDb,
102
- params: RequestCrossOrgLinkParams,
103
- ): Promise<CrossOrgLink> {
104
- if (params.sourceOrgId === params.targetOrgId) {
105
- throw new Error('Use same-org relationships for links within one organization')
106
- }
107
-
108
- const existing = await findCrossOrgLink(db, params)
109
- const action = resolveLinkRequest(existing)
110
- if (action === 'block') {
111
- throw new Error('A cross-org link already exists or is awaiting approval')
112
- }
113
-
114
- let link: CrossOrgLink
115
- if (action === 'reactivate' && existing) {
116
- const [row] = await db
117
- .update(crossOrgLinks)
118
- .set({ linkType: params.linkType, status: 'pending', rejectionReason: null, updatedAt: new Date() })
119
- .where(eq(crossOrgLinks.id, existing.id))
120
- .returning()
121
- link = row
122
- } else {
123
- const [row] = await db
124
- .insert(crossOrgLinks)
125
- .values({
126
- sourceOrgId: params.sourceOrgId,
127
- sourceEntityType: params.sourceEntityType,
128
- sourceEntityId: params.sourceEntityId,
129
- targetOrgId: params.targetOrgId,
130
- targetEntityType: params.targetEntityType,
131
- targetEntityId: params.targetEntityId,
132
- linkType: params.linkType,
133
- status: 'pending',
134
- createdBy: params.actorUserId,
135
- })
136
- .returning()
137
- link = row
138
- }
139
-
140
- await writeAuditLog(db, {
141
- action: 'federation.cross_org_link.request',
142
- entityType: 'cross_org_link',
143
- entityId: link.id,
144
- userId: params.actorUserId,
145
- organizationId: params.sourceOrgId,
146
- after: {
147
- sourceEntityId: params.sourceEntityId,
148
- targetEntityId: params.targetEntityId,
149
- linkType: params.linkType,
150
- targetOrgId: params.targetOrgId,
151
- },
152
- })
153
- return link
154
- }
155
-
156
- /** Internal: a target-org-only status transition with an audit row. */
157
- async function transitionByTarget(
158
- db: GovcoreDb,
159
- params: { linkId: string; orgId: string; actorUserId: string; reason?: string },
160
- status: 'active' | 'rejected',
161
- action: string,
162
- ): Promise<CrossOrgLink> {
163
- const [link] = await db
164
- .select()
165
- .from(crossOrgLinks)
166
- .where(and(eq(crossOrgLinks.id, params.linkId), eq(crossOrgLinks.targetOrgId, params.orgId)))
167
- .limit(1)
168
- if (!link) throw new Error('Cross-org link not found or not authorized')
169
- if (link.status !== 'pending') throw new Error('Only pending links can be approved or rejected')
170
-
171
- const [row] = await db
172
- .update(crossOrgLinks)
173
- .set({
174
- status,
175
- rejectionReason: status === 'rejected' && params.reason?.trim() ? params.reason.trim() : null,
176
- updatedAt: new Date(),
177
- })
178
- .where(eq(crossOrgLinks.id, params.linkId))
179
- .returning()
180
-
181
- await writeAuditLog(db, {
182
- action,
183
- entityType: 'cross_org_link',
184
- entityId: params.linkId,
185
- userId: params.actorUserId,
186
- organizationId: params.orgId,
187
- after: { sourceEntityId: link.sourceEntityId, targetEntityId: link.targetEntityId },
188
- })
189
- return row
190
- }
191
-
192
- export interface LinkDecisionParams {
193
- linkId: string
194
- /** The acting org — must be the link's target org. */
195
- orgId: string
196
- actorUserId: string
197
- }
198
-
199
- /** Approve a pending link. Target org only. Audited. */
200
- export async function approveCrossOrgLink(
201
- db: GovcoreDb,
202
- params: LinkDecisionParams,
203
- ): Promise<CrossOrgLink> {
204
- return transitionByTarget(db, params, 'active', 'federation.cross_org_link.approve')
205
- }
206
-
207
- /** Reject a pending link with an optional reason. Target org only. Audited. */
208
- export async function rejectCrossOrgLink(
209
- db: GovcoreDb,
210
- params: LinkDecisionParams & { reason?: string },
211
- ): Promise<CrossOrgLink> {
212
- return transitionByTarget(db, params, 'rejected', 'federation.cross_org_link.reject')
213
- }
214
-
215
- /** Internal: delete a link the acting org owns on the given side, audited. */
216
- async function deleteOwnedLink(
217
- db: GovcoreDb,
218
- params: { linkId: string; orgId: string; actorUserId: string; requireActive?: boolean },
219
- ownerColumn: typeof crossOrgLinks.sourceOrgId | typeof crossOrgLinks.targetOrgId,
220
- action: string,
221
- ): Promise<CrossOrgLink> {
222
- const [link] = await db
223
- .select()
224
- .from(crossOrgLinks)
225
- .where(and(eq(crossOrgLinks.id, params.linkId), eq(ownerColumn, params.orgId)))
226
- .limit(1)
227
- if (!link) throw new Error('Cross-org link not found or not authorized')
228
- if (params.requireActive && link.status !== 'active') {
229
- throw new Error('Only active links can be revoked')
230
- }
231
-
232
- await db.delete(crossOrgLinks).where(eq(crossOrgLinks.id, params.linkId))
233
-
234
- await writeAuditLog(db, {
235
- action,
236
- entityType: 'cross_org_link',
237
- entityId: params.linkId,
238
- userId: params.actorUserId,
239
- organizationId: params.orgId,
240
- before: {
241
- sourceEntityId: link.sourceEntityId,
242
- targetEntityId: link.targetEntityId,
243
- status: link.status,
244
- },
245
- })
246
- return link
247
- }
248
-
249
- /** Withdraw a link the *source* org proposed (any status). Audited. */
250
- export async function withdrawCrossOrgLink(
251
- db: GovcoreDb,
252
- params: LinkDecisionParams,
253
- ): Promise<CrossOrgLink> {
254
- return deleteOwnedLink(db, params, crossOrgLinks.sourceOrgId, 'federation.cross_org_link.withdraw')
255
- }
256
-
257
- /** Revoke an active link from the *target* org. Audited. */
258
- export async function revokeCrossOrgLink(
259
- db: GovcoreDb,
260
- params: LinkDecisionParams,
261
- ): Promise<CrossOrgLink> {
262
- return deleteOwnedLink(
263
- db,
264
- { ...params, requireActive: true },
265
- crossOrgLinks.targetOrgId,
266
- 'federation.cross_org_link.revoke',
267
- )
268
- }
269
-
270
- /**
271
- * Flag every pending/active link touching an entity for review — used when the
272
- * entity's visibility drops so both orgs are alerted the link may no longer be
273
- * accessible. Caller must have verified the entity belongs to its org.
274
- */
275
- export async function flagLinksForVisibilityDrop(
276
- db: GovcoreDb,
277
- entityType: string,
278
- entityId: string,
279
- reason: string,
280
- ): Promise<void> {
281
- await db
282
- .update(crossOrgLinks)
283
- .set({ flaggedForReview: true, flagReason: reason, updatedAt: new Date() })
284
- .where(and(touchesEntity(entityType, entityId), inArray(crossOrgLinks.status, ['pending', 'active'])))
285
- }
286
-
287
- /** Clear review flags on an entity's links — used when visibility is raised back. */
288
- export async function clearLinksFlag(
289
- db: GovcoreDb,
290
- entityType: string,
291
- entityId: string,
292
- ): Promise<void> {
293
- await db
294
- .update(crossOrgLinks)
295
- .set({ flaggedForReview: false, flagReason: null, updatedAt: new Date() })
296
- .where(and(touchesEntity(entityType, entityId), eq(crossOrgLinks.flaggedForReview, true)))
297
- }
298
-
299
- /**
300
- * Delete every cross-org link between two orgs — called when their connection is
301
- * removed. Audits a snapshot of the removed links (when any) under the actor.
302
- * Returns the removed rows.
303
- */
304
- export async function removeLinksForConnection(
305
- db: GovcoreDb,
306
- orgAId: string,
307
- orgBId: string,
308
- actorUserId: string,
309
- actorOrgId: string,
310
- ): Promise<CrossOrgLink[]> {
311
- const affected = await db.select().from(crossOrgLinks).where(betweenOrgs(orgAId, orgBId))
312
- if (affected.length === 0) return []
313
-
314
- await db.delete(crossOrgLinks).where(betweenOrgs(orgAId, orgBId))
315
-
316
- await writeAuditLog(db, {
317
- action: 'federation.cross_org_link.remove_for_connection',
318
- entityType: 'org_connection',
319
- userId: actorUserId,
320
- organizationId: actorOrgId,
321
- before: {
322
- orgAId,
323
- orgBId,
324
- removedLinkIds: affected.map((l) => l.id),
325
- },
326
- })
327
- return affected
328
- }
package/src/visibility.ts DELETED
@@ -1,110 +0,0 @@
1
- // @govcore/federation/visibility — federated read scoping.
2
- //
3
- // Content rows carry a `visibility` (org | connections | instance). A list query
4
- // is scoped to the caller's org by default; broadening to `federated` also pulls
5
- // in instance-wide rows and rows owned by a *connected* org whose visibility
6
- // permits it. These helpers build the query condition and answer single-row
7
- // read checks; the content tables themselves are app-owned (content is a later
8
- // milestone), so callers pass the columns.
9
-
10
- import { and, eq, inArray, or, type SQL } from 'drizzle-orm'
11
- import type { AnyPgColumn } from 'drizzle-orm/pg-core'
12
- import { orgConnections, type GovcoreDb } from '@govcore/schema'
13
-
14
- /** A content row's visibility vocabulary (mirrors the `visibility` schema enum). */
15
- export type FederationVisibility = 'org' | 'connections' | 'instance'
16
-
17
- /**
18
- * List-view scope. `org` (default) restricts to the caller's own org;
19
- * `federated` is an explicit, user-requested broadening so list queries never
20
- * silently fan out across every visible org.
21
- */
22
- export type ListScope = 'org' | 'federated'
23
-
24
- /** Coerce a raw query-string value into a ListScope, defaulting to `org`. */
25
- export function parseListScope(value: string | string[] | undefined): ListScope {
26
- return value === 'federated' ? 'federated' : 'org'
27
- }
28
-
29
- /**
30
- * Build the visibility WHERE condition for an org-scoped list query.
31
- *
32
- * - `org` scope → only rows owned by `orgId`.
33
- * - `federated` scope → owned rows, plus instance-wide rows, plus
34
- * connections/instance rows owned by a connected org.
35
- *
36
- * Pass `connectedOrgIds` empty under `org` scope (callers should skip the
37
- * connected-org lookup there). Compose with any status filter via `and(...)`.
38
- */
39
- export function listScopeFilter(
40
- cols: { organizationId: AnyPgColumn; visibility: AnyPgColumn },
41
- opts: { orgId: string; scope: ListScope; connectedOrgIds?: string[] },
42
- ): SQL {
43
- const base = eq(cols.organizationId, opts.orgId)
44
- if (opts.scope === 'org') return base
45
-
46
- const instanceWide = eq(cols.visibility, 'instance')
47
- const connectedOrgIds = opts.connectedOrgIds ?? []
48
- if (connectedOrgIds.length === 0) return or(base, instanceWide)!
49
- return or(
50
- base,
51
- instanceWide,
52
- and(
53
- inArray(cols.organizationId, connectedOrgIds),
54
- inArray(cols.visibility, ['connections', 'instance']),
55
- ),
56
- )!
57
- }
58
-
59
- /**
60
- * Throw if an entity's owning org doesn't match the caller's. Use before any
61
- * write to content fetched without an org filter.
62
- */
63
- export function assertOwnership(
64
- entityOrgId: string | null | undefined,
65
- callerOrgId: string,
66
- ): void {
67
- if (!entityOrgId || entityOrgId !== callerOrgId) {
68
- throw new Error('Forbidden: content owned by another organization')
69
- }
70
- }
71
-
72
- /** Org IDs with an *active* bilateral connection to `organizationId` (either direction). */
73
- export async function getConnectedOrgIds(
74
- db: GovcoreDb,
75
- organizationId: string,
76
- ): Promise<string[]> {
77
- const rows = await db
78
- .select({ fromOrgId: orgConnections.fromOrgId, toOrgId: orgConnections.toOrgId })
79
- .from(orgConnections)
80
- .where(
81
- and(
82
- or(
83
- eq(orgConnections.fromOrgId, organizationId),
84
- eq(orgConnections.toOrgId, organizationId),
85
- ),
86
- eq(orgConnections.status, 'active'),
87
- ),
88
- )
89
- return rows.map((r) => (r.fromOrgId === organizationId ? r.toOrgId : r.fromOrgId))
90
- }
91
-
92
- /**
93
- * Whether `callerOrgId` may read a single entity given its owning org and
94
- * visibility. Own-org always; `instance` always; `connections` only when an
95
- * active connection exists. A null org/visibility is unreadable.
96
- */
97
- export async function canReadFederatedEntity(
98
- db: GovcoreDb,
99
- entityOrgId: string | null | undefined,
100
- visibility: FederationVisibility | null | undefined,
101
- callerOrgId: string,
102
- ): Promise<boolean> {
103
- if (!entityOrgId || !visibility) return false
104
- if (entityOrgId === callerOrgId) return true
105
- if (visibility === 'instance') return true
106
- if (visibility !== 'connections') return false
107
-
108
- const connectedOrgIds = await getConnectedOrgIds(db, callerOrgId)
109
- return connectedOrgIds.includes(entityOrgId)
110
- }