Skip to content

feat: introduce ims authorization #832

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
May 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
580122f
feat: introduce ims authentication
dzehnder Mar 27, 2025
9c937c3
chore: api-doc
solaris007 Mar 28, 2025
a9dafc7
fix: adding mock auth context to tests
dzehnder Mar 28, 2025
40abe31
fix: PR review
dzehnder Mar 28, 2025
fd2151d
fix: adding auth to suggestions
dzehnder Mar 28, 2025
db0b14f
fix: userHasSubService for auto-fix
dzehnder Mar 31, 2025
30dcd3f
fix: introduce access control util
dzehnder Mar 31, 2025
ced6bf2
feat: adding ims auth to audits
dzehnder Mar 31, 2025
1ab7191
Merge branch 'main' into feat-ims-login
solaris007 Apr 1, 2025
aef12e9
feat: adding ims auth to audits
dzehnder Apr 2, 2025
c91a9d1
Merge branch 'refs/heads/main' into feat-ims-login
dzehnder Apr 3, 2025
bbf6596
fix: package lock
dzehnder Apr 3, 2025
80e7e40
Merge branch 'feat-ims-login' of github.com:adobe/spacecat-api-servic…
dzehnder Apr 3, 2025
db27635
fix: tmp fix failing tests
dzehnder Apr 3, 2025
46e4677
Merge branch 'refs/heads/main' into feat-ims-login
dzehnder Apr 4, 2025
ef37039
fix: access control returns admin access if not jwt
dzehnder Apr 4, 2025
92de5a8
fix: adding access control to all endpoints
dzehnder Apr 7, 2025
1d12d20
fix: brands test
dzehnder Apr 7, 2025
b58e8fc
fix: slack bot anonymous calls
dzehnder Apr 9, 2025
868115f
fix: tests not having path info
dzehnder Apr 9, 2025
0d434bb
fix: update http utils
dzehnder Apr 14, 2025
6b773c7
fix: failing tests
dzehnder Apr 15, 2025
c611f33
Merge branch 'refs/heads/main' into feat-ims-login
dzehnder Apr 17, 2025
8f241b3
fix: update package lock
dzehnder Apr 17, 2025
7b8efcf
fix: update http utils
dzehnder Apr 17, 2025
c6a4a1d
fix: update http utils
dzehnder Apr 17, 2025
5a6c840
fix: update http utils
dzehnder Apr 17, 2025
9bfe75d
fix: increasing test coverage
ravverma Apr 25, 2025
0915359
fix: test coverage to 100
dzehnder Apr 28, 2025
a93f4b5
Merge branch 'main' into feat-ims-login
dzehnder Apr 28, 2025
1ff9c3c
fix: merge and adjust new endpoint
dzehnder Apr 28, 2025
1e73262
fix: adding 403 api doc
dzehnder May 7, 2025
e6e8ebd
fix: adding 403 api doc
dzehnder May 7, 2025
1358365
Merge branch 'main' into feat-ims-login
dzehnder May 7, 2025
f529962
fix: update http utils
dzehnder May 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions docs/index.html

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ paths:
$ref: './auth-api.yaml#/google-auth'
/auth/google/{siteId}/status:
$ref: './auth-api.yaml#/google-auth-status'
/auth/login:
$ref: './auth-api.yaml#/login'
/configurations:
$ref: './configurations-api.yaml#/configurations'
/configurations/latest:
Expand Down
55 changes: 55 additions & 0 deletions docs/openapi/auth-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,58 @@ google-auth-status:
'500':
$ref: './responses.yaml#/500'
security: []
login:
post:
summary: Authenticate with access token
description: |
Authenticates a user using an IMS access token and returns session information.
operationId: login
tags:
- auth
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accessToken
properties:
accessToken:
type: string
description: The access token to authenticate with
responses:
'200':
description: Login successful
content:
application/json:
schema:
type: object
properties:
sessionToken:
type: string
description: A JWT token signed by the service containing the user profile and tenants.
'401':
description: Authentication failed
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: Error message
example: "Invalid access token"

'403':
description: Forbidden
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: Error message
example: "Access denied"
security: [] # No security required for login endpoint
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"@adobe/helix-universal-logger": "3.0.24",
"@adobe/spacecat-shared-data-access": "2.17.1",
"@adobe/spacecat-shared-gpt-client": "1.5.9",
"@adobe/spacecat-shared-http-utils": "1.10.3",
"@adobe/spacecat-shared-http-utils": "1.10.4",
"@adobe/spacecat-shared-ims-client": "1.7.3",
"@adobe/spacecat-shared-rum-api-client": "2.23.4",
"@adobe/spacecat-shared-brand-client": "1.1.6",
Expand Down
56 changes: 50 additions & 6 deletions src/controllers/audits.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {
badRequest,
badRequest, forbidden,
notFound,
ok,
} from '@adobe/spacecat-shared-http-utils';
Expand All @@ -20,27 +20,36 @@ import {
isNonEmptyArray,
isObject,
isValidUUID,
isValidUrl,
isValidUrl, isNonEmptyObject,
} from '@adobe/spacecat-shared-utils';
import { Config } from '@adobe/spacecat-shared-data-access/src/models/site/config.js';

import { AuditDto } from '../dto/audit.js';
import AccessControlUtil from '../support/access-control-util.js';

/**
* Audits controller.
* @param {DataAccess} dataAccess - Data access.
* @param {object} ctx - Context object.
* @returns {object} Audits controller.
* @constructor
*/
function AuditsController(dataAccess) {
if (!isObject(dataAccess)) {
function AuditsController(ctx) {
if (!isNonEmptyObject(ctx)) {
throw new Error('Context required');
}

const { dataAccess } = ctx;

if (!isNonEmptyObject(dataAccess)) {
throw new Error('Data access required');
}

const {
Audit, Configuration, LatestAudit, Site,
} = dataAccess;

const accessControlUtil = AccessControlUtil.fromContext(ctx);

/**
* Gets all audits for a given site and audit type. If no audit type is specified,
* all audits are returned.
Expand All @@ -56,6 +65,15 @@ function AuditsController(dataAccess) {
return badRequest('Site ID required');
}

const site = await Site.findById(siteId);
if (!site) {
return notFound('Site not found');
}

if (!await accessControlUtil.hasAccess(site)) {
return forbidden('User does not have access to this site');
}

const method = auditType
? Audit.allBySiteIdAndAuditType(siteId, auditType, { order })
: Audit.allBySiteId(siteId, { order });
Expand All @@ -65,7 +83,7 @@ function AuditsController(dataAccess) {
};

/**
* Gets all audits for a given site and audit type. Sorts by auditedAt descending.
* Gets all audits for a given audit type. Sorts by auditedAt descending.
* If the url parameter ascending is set to true, sorts by auditedAt ascending.
*
* @returns {Promise<Response>} Array of audits response.
Expand All @@ -78,6 +96,10 @@ function AuditsController(dataAccess) {
return badRequest('Audit type required');
}

if (!accessControlUtil.hasAdminAccess()) {
return forbidden('Admin access required');
}

const audits = (await LatestAudit.allByAuditType(auditType, { order }))
.map((audit) => AuditDto.toAbbreviatedJSON(audit));

Expand All @@ -95,6 +117,15 @@ function AuditsController(dataAccess) {
return badRequest('Site ID required');
}

const site = await Site.findById(siteId);
if (!site) {
return notFound('Site not found');
}

if (!await accessControlUtil.hasAccess(site)) {
return forbidden('User does not have access to this site');
}

const audits = (await LatestAudit.allBySiteId(siteId))
.map((audit) => AuditDto.toJSON(audit));

Expand All @@ -117,6 +148,15 @@ function AuditsController(dataAccess) {
return badRequest('Audit type required');
}

const site = await Site.findById(siteId);
if (!site) {
return notFound('Site not found');
}

if (!await accessControlUtil.hasAccess(site)) {
return forbidden('User does not have access to this site');
}

const audits = await LatestAudit.allBySiteIdAndAuditType(siteId, auditType);
if (isNonEmptyArray(audits)) {
return ok(AuditDto.toJSON(audits[0]));
Expand Down Expand Up @@ -167,6 +207,10 @@ function AuditsController(dataAccess) {
return notFound('Site not found');
}

if (!await accessControlUtil.hasAccess(site)) {
return forbidden('User does not have access to this site');
}

const configuration = await Configuration.findLatest();
const registeredAudits = configuration.getHandlers();
if (!registeredAudits[auditType]) {
Expand Down
28 changes: 22 additions & 6 deletions src/controllers/brands.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,38 +14,45 @@ import {
badRequest,
notFound,
ok,
createResponse,
createResponse, forbidden,
} from '@adobe/spacecat-shared-http-utils';
import {
hasText,
isObject,
isNonEmptyObject,
isValidUUID,
} from '@adobe/spacecat-shared-utils';

import { ErrorWithStatusCode, getImsUserToken } from '../support/utils.js';
import {
STATUS_BAD_REQUEST,
} from '../utils/constants.js';
import AccessControlUtil from '../support/access-control-util.js';

const HEADER_ERROR = 'x-error';

/**
* BrandsController. Provides methods to read brands and brand guidelines.
* @param {DataAccess} dataAccess - Data access.
* @param {object} ctx - Context of the request.
* @param {Object} env - Environment object.
* @returns {object} Brands controller.
* @constructor
*/
function BrandsController(dataAccess, log, env) {
if (!isObject(dataAccess)) {
function BrandsController(ctx, log, env) {
if (!isNonEmptyObject(ctx)) {
throw new Error('Context required');
}
const { dataAccess } = ctx;
if (!isNonEmptyObject(dataAccess)) {
throw new Error('Data access required');
}

if (!isObject(env)) {
if (!isNonEmptyObject(env)) {
throw new Error('Environment object required');
}
const { Organization, Site } = dataAccess;

const accessControlUtil = AccessControlUtil.fromContext(ctx);

function createErrorResponse(error) {
return createResponse({ message: error.message }, error.status, {
[HEADER_ERROR]: error.message,
Expand All @@ -68,6 +75,11 @@ function BrandsController(dataAccess, log, env) {
if (!organization) {
return notFound(`Organization not found: ${organizationId}`);
}

if (!await accessControlUtil.hasAccess(organization)) {
return forbidden('Only users belonging to the organization can view its brands');
}

const imsOrgId = organization.getImsOrgId();
const imsUserToken = getImsUserToken(context);
const brandClient = BrandClient.createFrom(context);
Expand Down Expand Up @@ -120,6 +132,10 @@ function BrandsController(dataAccess, log, env) {
if (!site) {
return notFound(`Site not found: ${siteId}`);
}
if (!await accessControlUtil.hasAccess(site)) {
return forbidden('Only users belonging to the organization of the site can view its brand guidelines');
}

const brandId = site.getConfig()?.getBrandConfig()?.brandId;
log.info(`Brand ID mapping for site: ${siteId} is ${brandId}`);
if (!hasText(brandId)) {
Expand Down
23 changes: 20 additions & 3 deletions src/controllers/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,39 @@

import {
badRequest,
forbidden,
notFound,
ok,
} from '@adobe/spacecat-shared-http-utils';
import {
isInteger,
isObject,
isNonEmptyObject,
} from '@adobe/spacecat-shared-utils';

import { ConfigurationDto } from '../dto/configuration.js';
import AccessControlUtil from '../support/access-control-util.js';

function ConfigurationController(dataAccess) {
if (!isObject(dataAccess)) {
function ConfigurationController(ctx) {
if (!isNonEmptyObject(ctx)) {
throw new Error('Context required');
}
const { dataAccess } = ctx;
if (!isNonEmptyObject(dataAccess)) {
throw new Error('Data access required');
}

const { Configuration } = dataAccess;

const accessControlUtil = AccessControlUtil.fromContext(ctx);

/**
* Retrieves all configurations (all versions).
* @return {Promise<Response>} Array of configurations.
*/
const getAll = async () => {
if (!accessControlUtil.hasAdminAccess()) {
return forbidden('Only admins can view configurations');
}
const configurations = (await Configuration.all())
.map((configuration) => ConfigurationDto.toJSON(configuration));
return ok(configurations);
Expand All @@ -45,6 +56,9 @@ function ConfigurationController(dataAccess) {
* @return {Promise<Response>} Configuration response.
*/
const getByVersion = async (context) => {
if (!accessControlUtil.hasAdminAccess()) {
return forbidden('Only admins can view configurations');
}
const configurationVersion = context.params?.version;

if (!isInteger(configurationVersion)) {
Expand All @@ -64,6 +78,9 @@ function ConfigurationController(dataAccess) {
* @return {Promise<Response>} Configuration response.
*/
const getLatest = async () => {
if (!accessControlUtil.hasAdminAccess()) {
return forbidden('Only admins can view configurations');
}
const configuration = await Configuration.findLatest();
if (!configuration) {
return notFound('Configuration not found');
Expand Down
Loading