Skip to content

Commit 50c4ec2

Browse files
authored
Merge branch 'main' into fix/cloud-manager-client-zip-lib-symlinks
2 parents 70785ab + 3b7d348 commit 50c4ec2

File tree

11 files changed

+452
-53
lines changed

11 files changed

+452
-53
lines changed

packages/spacecat-shared-data-access/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
17
## [@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)
28

39
### Features

packages/spacecat-shared-data-access/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@adobe/spacecat-shared-data-access",
3-
"version": "3.25.0",
3+
"version": "3.26.0",
44
"description": "Shared modules of the Spacecat Services - Data Access",
55
"type": "module",
66
"engines": {

packages/spacecat-shared-data-access/src/models/base/base.collection.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,20 @@ class BaseCollection {
585585
return this.#queryByIndexKeys(keys, { ...options, limit: 1 });
586586
}
587587

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+
588602
async findById(id) {
589603
guardId(this.idName, id, this.entityName);
590604
if (this.entity) {

packages/spacecat-shared-data-access/src/models/site-ims-org-access/index.d.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,31 +35,30 @@ export interface SiteImsOrgAccess extends BaseModel {
3535
}
3636

3737
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;
4839
targetOrganization: {
4940
id: string;
5041
imsOrgId: string;
5142
};
5243
}
5344

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+
5451
export interface SiteImsOrgAccessCollection extends
5552
BaseCollection<SiteImsOrgAccess> {
5653
allBySiteId(siteId: string): Promise<SiteImsOrgAccess[]>;
5754
allByOrganizationId(organizationId: string): Promise<SiteImsOrgAccess[]>;
5855
allByTargetOrganizationId(targetOrganizationId: string): Promise<SiteImsOrgAccess[]>;
5956
allByOrganizationIdWithTargetOrganization(organizationId: string): Promise<SiteImsOrgAccessGrantWithTarget[]>;
6057
allByOrganizationIdsWithTargetOrganization(organizationIds: string[]): Promise<SiteImsOrgAccessGrantWithTarget[]>;
58+
allByOrganizationIdWithSites(organizationId: string): Promise<SiteImsOrgAccessGrantWithSite[]>;
6159

6260
findBySiteId(siteId: string): Promise<SiteImsOrgAccess | null>;
6361
findByOrganizationId(organizationId: string): Promise<SiteImsOrgAccess | null>;
6462
findByTargetOrganizationId(targetOrganizationId: string): Promise<SiteImsOrgAccess | null>;
63+
findBySiteIdAndOrganizationIdAndProductCode(siteId: string, organizationId: string, productCode: EntitlementProductCode): Promise<SiteImsOrgAccess | null>;
6564
}

packages/spacecat-shared-data-access/src/models/site-ims-org-access/site-ims-org-access.collection.js

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,16 @@ class SiteImsOrgAccessCollection extends BaseCollection {
7979
}
8080

8181
/**
82+
* Shared pagination loop for PostgREST embedding queries. Fetches all pages and maps
83+
* each row using the provided mapper function.
84+
*
8285
* @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>}
8489
* @private
8590
*/
86-
async #fetchGrantsWithTargetOrg(query) {
91+
async #fetchPaginatedGrants(query, mapRow, errorMessage) {
8792
const allResults = [];
8893
let offset = 0;
8994
let keepGoing = true;
@@ -94,9 +99,9 @@ class SiteImsOrgAccessCollection extends BaseCollection {
9499
const { data, error } = await orderedQuery.range(offset, offset + DEFAULT_PAGE_SIZE - 1);
95100

96101
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);
98103
throw new DataAccessError(
99-
'Failed to query grants with target organization',
104+
errorMessage,
100105
{ entityName: 'SiteImsOrgAccess', tableName: 'site_ims_org_accesses' },
101106
error,
102107
);
@@ -111,22 +116,23 @@ class SiteImsOrgAccessCollection extends BaseCollection {
111116
}
112117
}
113118

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+
);
130136
}
131137

132138
/**
@@ -185,6 +191,74 @@ class SiteImsOrgAccessCollection extends BaseCollection {
185191
this.postgrestService.from('site_ims_org_accesses').select(select).in('organization_id', organizationIds),
186192
);
187193
}
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+
}
188262
}
189263

190264
export default SiteImsOrgAccessCollection;

packages/spacecat-shared-data-access/test/it/site-ims-org-access/site-ims-org-access.test.js

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ describe('SiteImsOrgAccess IT', async () => {
165165
for (const entry of entries) {
166166
expect(entry).to.have.property('grant');
167167
expect(entry).to.have.property('targetOrganization');
168-
expect(entry.grant.organizationId).to.equal(organizationId);
169-
expect(entry.targetOrganization.id).to.equal(entry.grant.targetOrganizationId);
168+
expect(entry.grant.getOrganizationId()).to.equal(organizationId);
169+
expect(entry.targetOrganization.id).to.equal(entry.grant.getTargetOrganizationId());
170170
}
171171
});
172172

@@ -183,13 +183,58 @@ describe('SiteImsOrgAccess IT', async () => {
183183
for (const entry of entries) {
184184
expect(entry).to.have.property('grant');
185185
expect(entry).to.have.property('targetOrganization');
186-
expect(entry.grant.organizationId).to.equal(organizationId);
187-
expect(entry.grant.targetOrganizationId).to.be.a('string');
188-
expect(entry.targetOrganization.id).to.equal(entry.grant.targetOrganizationId);
186+
expect(entry.grant.getOrganizationId()).to.equal(organizationId);
187+
expect(entry.grant.getTargetOrganizationId()).to.be.a('string');
188+
expect(entry.targetOrganization.id).to.equal(entry.grant.getTargetOrganizationId());
189189
expect(entry.targetOrganization.imsOrgId).to.be.a('string');
190190
}
191191
});
192192

193+
it('finds a grant by siteId, organizationId and productCode', async () => {
194+
const sample = sampleData.siteImsOrgAccesses[0];
195+
196+
const grant = await SiteImsOrgAccess.findBySiteIdAndOrganizationIdAndProductCode(
197+
sample.getSiteId(),
198+
sample.getOrganizationId(),
199+
sample.getProductCode(),
200+
);
201+
202+
expect(grant).to.be.an('object');
203+
expect(grant.getId()).to.equal(sample.getId());
204+
expect(grant.getSiteId()).to.equal(sample.getSiteId());
205+
expect(grant.getOrganizationId()).to.equal(sample.getOrganizationId());
206+
expect(grant.getProductCode()).to.equal(sample.getProductCode());
207+
});
208+
209+
it('returns null when no grant matches findBySiteIdAndOrganizationIdAndProductCode', async () => {
210+
const result = await SiteImsOrgAccess.findBySiteIdAndOrganizationIdAndProductCode(
211+
sampleData.siteImsOrgAccesses[0].getSiteId(),
212+
sampleData.siteImsOrgAccesses[0].getOrganizationId(),
213+
'ACO', // different productCode — no fixture has this combination
214+
);
215+
216+
expect(result).to.be.null;
217+
});
218+
219+
it('gets all grants with embedded site data for a delegate organization', async () => {
220+
const sample = sampleData.siteImsOrgAccesses[0];
221+
const organizationId = sample.getOrganizationId();
222+
223+
const entries = await SiteImsOrgAccess.allByOrganizationIdWithSites(organizationId);
224+
225+
expect(entries).to.be.an('array');
226+
expect(entries.length).to.be.greaterThan(0);
227+
228+
for (const entry of entries) {
229+
expect(entry).to.have.property('grant');
230+
expect(entry).to.have.property('site');
231+
expect(entry.grant.getOrganizationId()).to.equal(organizationId);
232+
expect(entry.grant.getSiteId()).to.be.a('string');
233+
expect(entry.site).to.be.an('object');
234+
expect(entry.site.getId()).to.equal(entry.grant.getSiteId());
235+
}
236+
});
237+
193238
it('removes a site ims org access', async () => {
194239
const access = await SiteImsOrgAccess.findById(sampleData.siteImsOrgAccesses[1].getId());
195240

0 commit comments

Comments
 (0)