@govcore/federation 0.2.1 → 0.2.3
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/index.d.ts +139 -4
- package/dist/index.js +290 -14
- package/dist/index.js.map +1 -0
- package/package.json +26 -10
- package/CHANGELOG.md +0 -47
- package/dist/connections.d.ts +0 -30
- package/dist/connections.d.ts.map +0 -1
- package/dist/connections.js +0 -83
- package/dist/index.d.ts.map +0 -1
- package/dist/links.d.ts +0 -64
- package/dist/links.d.ts.map +0 -1
- package/dist/links.js +0 -214
- package/dist/visibility.d.ts +0 -45
- package/dist/visibility.d.ts.map +0 -1
- package/dist/visibility.js +0 -68
- package/src/connections.ts +0 -137
- package/src/index.ts +0 -15
- package/src/links.ts +0 -328
- package/src/visibility.ts +0 -110
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
|
-
}
|