Skip to content

feat: add access control helpers for cross-org delegation (Option 2a)#1453

Merged
ekremney merged 4 commits intomainfrom
feat/cross-org-delegation-access-control-helpers
Mar 19, 2026
Merged

feat: add access control helpers for cross-org delegation (Option 2a)#1453
ekremney merged 4 commits intomainfrom
feat/cross-org-delegation-access-control-helpers

Conversation

@ekremney
Copy link
Copy Markdown
Member

@ekremney ekremney commented Mar 19, 2026

Summary

Follow-on to #1448. Adds the spacecat-shared primitives required to implement the access-check
decision flow (Path A / Path B) and the delegated-site dropdown in the api-service.

AuthInfo.isDelegatedTenantsComplete()spacecat-shared-http-utils

Returns true when the delegated_tenants_complete JWT claim is absent or true; false when
it is explicitly false.

hasAccess() in the api-service uses this to branch between two resolution paths:

Path Condition Behaviour
A complete = true (≤ 20 grants, typical agency) JWT gate: if target org not in delegated_tenants list, deny with zero DB calls. DB only hit when JWT confirms a match (revocation check).
B complete = false (> 20 grants, e.g. internal sales) Skip JWT gate. Read sourceOrganizationId from delegated_tenants[0] and go DB-direct every request.

Defaults to true when the claim is absent — backward-compat with tokens minted before the claim
was added (all such tokens had ≤ 20 grants).

BaseCollection.createInstanceFromRow(row)spacecat-shared-data-access

Public wrapper around the existing private #toModelRecord + #createInstance pipeline.
Enables other collections to convert an embedded PostgREST sub-row (snake_case columns) into a
proper model instance, reusing the full field-mapping logic without duplication.

SiteImsOrgAccessCollection additions — spacecat-shared-data-access

findBySiteIdAndOrganizationIdAndProductCode(siteId, organizationId, productCode)
Thin public wrapper around findByIndexKeys. Used by hasAccess() for the DB revocation check
(Path A) and the DB-direct lookup (Path B).

allByOrganizationIdWithSites(organizationId)
Single-round-trip PostgREST embedding query (sites!site_ims_org_accesses_site_id_fkey(*)).
Returns { grant: SiteImsOrgAccess, site: Site | null } pairs — both fields are proper model
instances with getters. Used by the api-service getSitesForOrganization endpoint to merge
delegated sites into the org's site list with no N+1.

All embedding methods now return SiteImsOrgAccess model instances for grant
Previously grant was a plain camelCase object. Now allByOrganizationIdWithTargetOrganization,
allByOrganizationIdsWithTargetOrganization, and allByOrganizationIdWithSites all return a
proper SiteImsOrgAccess model instance as grant. Callers use entry.grant.getOrganizationId(),
entry.grant.getExpiresAt(), etc. — consistent getters everywhere.

The now-unnecessary static #toGrant(row) helper was removed.

How these will be used in the api-service (next PR)

hasAccess() in access-control-util.js:

// Path A (complete = true)
const delegatedTenant = authInfo.getDelegatedTenant(imsOrgId, productCode);
if (!delegatedTenant) return false; // zero DB calls
const grant = await this.SiteImsOrgAccess
  .findBySiteIdAndOrganizationIdAndProductCode(siteId, delegatedTenant.sourceOrganizationId, productCode);
if (grant && (!grant.getExpiresAt() || new Date(grant.getExpiresAt()) > new Date())) {
  hasOrgAccess = true; isDelegatedAccess = true;
}

// Path B (complete = false)
const sourceOrganizationId = authInfo.getDelegatedTenants()[0]?.sourceOrganizationId;
const grant = await this.SiteImsOrgAccess
  .findBySiteIdAndOrganizationIdAndProductCode(siteId, sourceOrganizationId, productCode);

getSitesForOrganization in organizations.js:

const delegatedEntries = await SiteImsOrgAccess.allByOrganizationIdWithSites(organizationId);
for (const entry of delegatedEntries) {
  if (entry.grant.getProductCode() !== productCode) continue;
  if (entry.grant.getExpiresAt() && new Date(entry.grant.getExpiresAt()) <= now) continue;
  if (entry.site && !ownSiteIds.has(entry.site.getId())) sites.push(entry.site);
}

Test plan

  • AuthInfo.isDelegatedTenantsComplete() — 5 unit tests
  • SiteImsOrgAccessCollection — 33 unit tests (all embedding methods use getter assertions)
  • IT tests — 13 passing (all embedding methods verified against live PostgREST DB)

Related

🤖 Generated with Claude Code

ekremney and others added 2 commits March 19, 2026 21:02
Adds the spacecat-shared primitives needed to implement Path A/B access
resolution and the delegated-site dropdown in the api-service.

**spacecat-shared-http-utils / AuthInfo**
- `isDelegatedTenantsComplete()`: returns `true` when the
  `delegated_tenants_complete` JWT claim is absent or `true`; `false`
  when the claim is explicitly `false`. Used by `hasAccess()` to switch
  between the JWT-gate fast-deny path (Path A, ≤20 grants) and the
  DB-direct path (Path B, truncated list). Defaults to `true` for
  backward-compat with old tokens that predate the claim.

**spacecat-shared-data-access / BaseCollection**
- `createInstanceFromRow(row)`: public wrapper around the private
  `#toModelRecord` + `#createInstance` pipeline. Lets other collections
  convert an embedded PostgREST sub-row (snake_case) into a proper model
  instance without duplicating field-mapping logic.

**spacecat-shared-data-access / SiteImsOrgAccessCollection**
- `findBySiteIdAndOrganizationIdAndProductCode(siteId, orgId, product)`:
  thin public wrapper around `findByIndexKeys`. Used by `hasAccess()` for
  the DB revocation check (Path A) and DB-direct lookup (Path B).
- `allByOrganizationIdWithSites(organizationId)`: PostgREST embedding
  query (`sites!fkey(*)`) — returns `{ grant, site }` pairs in a single
  round-trip. `site` is a proper Site model instance via
  `BaseCollection.createInstanceFromRow`. Replaces the N+1
  `allByOrganizationId` + batch `Site.findById` pattern for the
  delegated-site dropdown.
- Internal refactor of the existing `#fetchGrantsWithTargetOrg`:
  extracted `static #toGrant(row)` (8-field snake→camelCase mapper) and
  `async #fetchPaginatedGrants(query, mapRow, errorMessage)` (shared
  pagination loop). Both existing embedding methods and the new
  `#fetchGrantsWithSite` are now thin wrappers — no behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The grant field in allByOrganizationIdWithTargetOrganization,
allByOrganizationIdsWithTargetOrganization, and allByOrganizationIdWithSites
was a plain camelCase object. It is now a proper SiteImsOrgAccess model instance,
so all three methods return consistent types (getters everywhere).

Removes the now-unnecessary static #toGrant helper and calls
this.createInstanceFromRow(row) directly in both #fetchGrantsWithTargetOrg
and #fetchGrantsWithSite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

This PR will trigger a minor release when merged.

ekremney and others added 2 commits March 19, 2026 21:13
grant is now a SiteImsOrgAccess model instance, so property access
(entry.grant.organizationId) must be replaced with getters
(entry.grant.getOrganizationId()). Same for siteId and targetOrganizationId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The all-collections-methods-coverage smoke test invokes every public
method including createInstanceFromRow (starts with 'create', treated as
mutating). With no args the call reached fromDbRecord(undefined) and
threw a TypeError that didn't match the expected-error patterns, failing
all 36 collection smoke tests.

Add an isNonEmptyObject guard so the method returns null gracefully
instead of throwing — consistent with #createInstance's own null-return
contract for empty records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ekremney ekremney requested a review from solaris007 March 19, 2026 20:29
@ekremney
Copy link
Copy Markdown
Member Author

@solaris007 this contains a bunch of helpers for the feature I will implement in api-service side. Due to time constraints, I will need to merge this. Please review, but I will address the reviews retroactively in a separate PR.

@ekremney ekremney merged commit 960623e into main Mar 19, 2026
6 checks passed
@ekremney ekremney deleted the feat/cross-org-delegation-access-control-helpers branch March 19, 2026 20:38
solaris007 pushed a commit that referenced this pull request Mar 19, 2026
## [@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)

### Features

* add access control helpers for cross-org delegation (Option 2a) ([#1453](#1453)) ([960623e](960623e)), closes [adobe/spacecat-auth-service#503](https://github.com/adobe/spacecat-auth-service/issues/503) [#1448](#1448)
@solaris007
Copy link
Copy Markdown
Member

🎉 This PR is included in version @adobe/spacecat-shared-data-access-v3.26.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

solaris007 pushed a commit that referenced this pull request Mar 19, 2026
## [@adobe/spacecat-shared-http-utils-v1.24.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-http-utils-v1.23.0...@adobe/spacecat-shared-http-utils-v1.24.0) (2026-03-19)

### Features

* add access control helpers for cross-org delegation (Option 2a) ([#1453](#1453)) ([960623e](960623e)), closes [adobe/spacecat-auth-service#503](https://github.com/adobe/spacecat-auth-service/issues/503) [#1448](#1448)
@solaris007
Copy link
Copy Markdown
Member

🎉 This PR is included in version @adobe/spacecat-shared-http-utils-v1.24.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants