@adobe/spacecat-shared-data-access 3.25.0 → 3.26.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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [@adobe/spacecat-shared-data-access-v3.26.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v3.25.0...@adobe/spacecat-shared-data-access-v3.26.0) (2026-03-19)
2
+
3
+ ### Features
4
+
5
+ * add access control helpers for cross-org delegation (Option 2a) ([#1453](https://github.com/adobe/spacecat-shared/issues/1453)) ([960623e](https://github.com/adobe/spacecat-shared/commit/960623ea9e77e849be8b0a5bd7ee047309377cef)), closes [adobe/spacecat-auth-service#503](https://github.com/adobe/spacecat-auth-service/issues/503) [#1448](https://github.com/adobe/spacecat-shared/issues/1448)
6
+
1
7
  ## [@adobe/spacecat-shared-data-access-v3.25.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v3.24.0...@adobe/spacecat-shared-data-access-v3.25.0) (2026-03-19)
2
8
 
3
9
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "3.25.0",
3
+ "version": "3.26.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -585,6 +585,20 @@ class BaseCollection {
585
585
  return this.#queryByIndexKeys(keys, { ...options, limit: 1 });
586
586
  }
587
587
 
588
+ /**
589
+ * Converts a raw PostgREST row (snake_case) to a model instance using the same field mapping
590
+ * pipeline as the internal create/find paths. Intended for use by collections that receive
591
+ * embedded sub-rows from PostgREST resource embedding (e.g. `sites!fkey(*)`), allowing them
592
+ * to return proper model instances rather than raw snake_case objects.
593
+ *
594
+ * @param {object} row - Raw PostgREST row with snake_case column names.
595
+ * @returns {object|null} A model instance, or null if the row is empty/invalid.
596
+ */
597
+ createInstanceFromRow(row) {
598
+ if (!isNonEmptyObject(row)) return null;
599
+ return this.#createInstance(this.#toModelRecord(row));
600
+ }
601
+
588
602
  async findById(id) {
589
603
  guardId(this.idName, id, this.entityName);
590
604
  if (this.entity) {
@@ -35,22 +35,19 @@ export interface SiteImsOrgAccess extends BaseModel {
35
35
  }
36
36
 
37
37
  export interface SiteImsOrgAccessGrantWithTarget {
38
- grant: {
39
- id: string;
40
- siteId: string;
41
- organizationId: string;
42
- targetOrganizationId: string;
43
- productCode: EntitlementProductCode;
44
- role: SiteImsOrgAccessRole;
45
- grantedBy: string | null;
46
- expiresAt: string | null;
47
- };
38
+ grant: SiteImsOrgAccess;
48
39
  targetOrganization: {
49
40
  id: string;
50
41
  imsOrgId: string;
51
42
  };
52
43
  }
53
44
 
45
+ export interface SiteImsOrgAccessGrantWithSite {
46
+ grant: SiteImsOrgAccess;
47
+ /** Site model instance. Null only if the FK is broken (should not occur given ON DELETE CASCADE). */
48
+ site: Site | null;
49
+ }
50
+
54
51
  export interface SiteImsOrgAccessCollection extends
55
52
  BaseCollection<SiteImsOrgAccess> {
56
53
  allBySiteId(siteId: string): Promise<SiteImsOrgAccess[]>;
@@ -58,8 +55,10 @@ export interface SiteImsOrgAccessCollection extends
58
55
  allByTargetOrganizationId(targetOrganizationId: string): Promise<SiteImsOrgAccess[]>;
59
56
  allByOrganizationIdWithTargetOrganization(organizationId: string): Promise<SiteImsOrgAccessGrantWithTarget[]>;
60
57
  allByOrganizationIdsWithTargetOrganization(organizationIds: string[]): Promise<SiteImsOrgAccessGrantWithTarget[]>;
58
+ allByOrganizationIdWithSites(organizationId: string): Promise<SiteImsOrgAccessGrantWithSite[]>;
61
59
 
62
60
  findBySiteId(siteId: string): Promise<SiteImsOrgAccess | null>;
63
61
  findByOrganizationId(organizationId: string): Promise<SiteImsOrgAccess | null>;
64
62
  findByTargetOrganizationId(targetOrganizationId: string): Promise<SiteImsOrgAccess | null>;
63
+ findBySiteIdAndOrganizationIdAndProductCode(siteId: string, organizationId: string, productCode: EntitlementProductCode): Promise<SiteImsOrgAccess | null>;
65
64
  }
@@ -79,11 +79,16 @@ class SiteImsOrgAccessCollection extends BaseCollection {
79
79
  }
80
80
 
81
81
  /**
82
+ * Shared pagination loop for PostgREST embedding queries. Fetches all pages and maps
83
+ * each row using the provided mapper function.
84
+ *
82
85
  * @param {object} query - PostgREST query builder (result of .from(...).select(...))
83
- * @returns {Promise<Array<{grant: object, targetOrganization: object}>>}
86
+ * @param {Function} mapRow - Maps a raw row to the desired return shape
87
+ * @param {string} errorMessage - Used for logging and DataAccessError message
88
+ * @returns {Promise<Array>}
84
89
  * @private
85
90
  */
86
- async #fetchGrantsWithTargetOrg(query) {
91
+ async #fetchPaginatedGrants(query, mapRow, errorMessage) {
87
92
  const allResults = [];
88
93
  let offset = 0;
89
94
  let keepGoing = true;
@@ -94,9 +99,9 @@ class SiteImsOrgAccessCollection extends BaseCollection {
94
99
  const { data, error } = await orderedQuery.range(offset, offset + DEFAULT_PAGE_SIZE - 1);
95
100
 
96
101
  if (error) {
97
- this.log.error(`[SiteImsOrgAccess] Failed to query grants with target org - ${error.message}`, error);
102
+ this.log.error(`[SiteImsOrgAccess] ${errorMessage} - ${error.message}`, error);
98
103
  throw new DataAccessError(
99
- 'Failed to query grants with target organization',
104
+ errorMessage,
100
105
  { entityName: 'SiteImsOrgAccess', tableName: 'site_ims_org_accesses' },
101
106
  error,
102
107
  );
@@ -111,22 +116,23 @@ class SiteImsOrgAccessCollection extends BaseCollection {
111
116
  }
112
117
  }
113
118
 
114
- return allResults.map((row) => ({
115
- grant: {
116
- id: row.id,
117
- siteId: row.site_id,
118
- organizationId: row.organization_id,
119
- targetOrganizationId: row.target_organization_id,
120
- productCode: row.product_code,
121
- role: row.role,
122
- grantedBy: row.granted_by,
123
- expiresAt: row.expires_at,
124
- },
125
- targetOrganization: {
126
- id: row.organizations.id,
127
- imsOrgId: row.organizations.ims_org_id,
128
- },
129
- }));
119
+ return allResults.map(mapRow);
120
+ }
121
+
122
+ /**
123
+ * @param {object} query - PostgREST query builder
124
+ * @returns {Promise<Array<{grant: SiteImsOrgAccess, targetOrganization: object}>>}
125
+ * @private
126
+ */
127
+ async #fetchGrantsWithTargetOrg(query) {
128
+ return this.#fetchPaginatedGrants(
129
+ query,
130
+ (row) => ({
131
+ grant: this.createInstanceFromRow(row),
132
+ targetOrganization: { id: row.organizations.id, imsOrgId: row.organizations.ims_org_id },
133
+ }),
134
+ 'Failed to query grants with target organization',
135
+ );
130
136
  }
131
137
 
132
138
  /**
@@ -185,6 +191,74 @@ class SiteImsOrgAccessCollection extends BaseCollection {
185
191
  this.postgrestService.from('site_ims_org_accesses').select(select).in('organization_id', organizationIds),
186
192
  );
187
193
  }
194
+
195
+ /**
196
+ * Finds a single grant by the compound key (siteId, organizationId, productCode).
197
+ * Used by hasAccess() in the api-service to verify a grant still exists (Path A revocation
198
+ * check) or to perform a direct DB lookup when the JWT list was truncated (Path B).
199
+ *
200
+ * Returns a model instance so callers can use getExpiresAt(), getRole(), etc.
201
+ * Returns null when no matching grant exists.
202
+ *
203
+ * @param {string} siteId - UUID of the site.
204
+ * @param {string} organizationId - UUID of the delegate organization.
205
+ * @param {string} productCode - Product code (e.g. 'LLMO', 'ASO').
206
+ * @returns {Promise<SiteImsOrgAccess|null>}
207
+ */
208
+ async findBySiteIdAndOrganizationIdAndProductCode(siteId, organizationId, productCode) {
209
+ if (!siteId || !organizationId || !productCode) {
210
+ throw new DataAccessError(
211
+ 'siteId, organizationId and productCode are required',
212
+ { entityName: 'SiteImsOrgAccess', tableName: 'site_ims_org_accesses' },
213
+ );
214
+ }
215
+ return this.findByIndexKeys({ siteId, organizationId, productCode });
216
+ }
217
+
218
+ /**
219
+ * Returns all grants for the given delegate organization with the full site row embedded
220
+ * via PostgREST resource embedding (INNER JOIN on site_id FK). This is a single round-trip
221
+ * query — no N+1 — suitable for populating the site dropdown for delegated users.
222
+ *
223
+ * Returns plain objects, not model instances. The `site` field contains the raw PostgREST
224
+ * row for the joined site (snake_case column names). It is null only when the FK is broken,
225
+ * which should not occur given ON DELETE CASCADE on site_id.
226
+ *
227
+ * @param {string} organizationId - UUID of the delegate organization.
228
+ * @returns {Promise<Array<{
229
+ * grant: {id: string, siteId: string, organizationId: string,
230
+ * targetOrganizationId: string, productCode: string, role: string,
231
+ * grantedBy: string|null, expiresAt: string|null},
232
+ * site: object|null
233
+ * }>>}
234
+ */
235
+ async allByOrganizationIdWithSites(organizationId) {
236
+ if (!organizationId) {
237
+ throw new DataAccessError('organizationId is required', { entityName: 'SiteImsOrgAccess', tableName: 'site_ims_org_accesses' });
238
+ }
239
+ // eslint-disable-next-line max-len
240
+ const select = 'id, site_id, organization_id, target_organization_id, product_code, role, granted_by, expires_at, sites!site_ims_org_accesses_site_id_fkey(*)';
241
+ return this.#fetchGrantsWithSite(
242
+ this.postgrestService.from('site_ims_org_accesses').select(select).eq('organization_id', organizationId),
243
+ );
244
+ }
245
+
246
+ /**
247
+ * @param {object} query - PostgREST query builder
248
+ * @returns {Promise<Array<{grant: SiteImsOrgAccess, site: Site|null}>>}
249
+ * @private
250
+ */
251
+ async #fetchGrantsWithSite(query) {
252
+ const siteCollection = this.entityRegistry.getCollection('SiteCollection');
253
+ return this.#fetchPaginatedGrants(
254
+ query,
255
+ (row) => ({
256
+ grant: this.createInstanceFromRow(row),
257
+ site: row.sites ? siteCollection.createInstanceFromRow(row.sites) : null,
258
+ }),
259
+ 'Failed to query grants with site',
260
+ );
261
+ }
188
262
  }
189
263
 
190
264
  export default SiteImsOrgAccessCollection;