Skip to content

Commit

Permalink
#4353 - SIMS Queue Dashboard - Access through IDIR (#4363)
Browse files Browse the repository at this point in the history
Used a "JWT token exchange" concept to allow access to the "Queues
Admin", considering an IDIR(Ministry) user already authenticated and
with a specific role.

> A JWT (JSON Web Token) exchange is a process where an existing JWT is
used to obtain a new JWT, often with different claims or for accessing
different resources.

_Note:_ Keycloak has a [token exchange preview
feature](https://www.keycloak.org/securing-apps/token-exchange) that
seems what was needed but is not enabled right now. Also, it would be
possible to use our Keycloak js lib and authenticate a second token, but
it does not seem to be recommendable and the current approach of
SIMS-API issuing a token seems good enough for now.

The SIMS-API will allow access to a specific endpoint
`users/queue-admin-token-exchange`, protected under a Ministry role
(`aest-queue-dashboard-admin`), to generate a cookie with a SIMS-API
issued token where its sole purpose is to allow access to the "Queues
Admin". Queues admin is now able to validate the generated token based
on a shared secret between the SIMS-API and queue-consumers that allows
the token to be signed and validated.

This token is saved in a cookie that will also be accessible by the
"Queues Admin", allowing its validation. The token has the security
properties sets (`httpOnly`, `secure`, and `sameSite`) to prevent
different security vulnerabilities. Some of those must be relaxed for
local development only.
_Note:_ `credentials: true` was added under the `app.enableCors` to
allow the generated cookie to be saved on the client, otherwise `Axios`
would not save it.
Please see below an example of what the generated token looks like, the
known [registered
claims](https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims#registered-claims)
were used.
```json
{
  "iss": "sims-api",
  "sub": "some-user-guid@idir",
  "aud": "queues-dashboard",
  "iat": 1739839795,
  "exp": 1739839915
}
```

The new way of accessing the "Queues Admin" is using the new link in the
Ministry portal. Once accessed, it will generate the token to allow the
user to access the queues dashboard.
The new Ministry role (`aest-queue-dashboard-admin`) is intended to
show/hide the button to avoid confusion to other users since this is
supposed to be accessible to only a few users.


![image](https://github.com/user-attachments/assets/2dc4eb79-ee86-45d6-8d68-47070f4ff836)

## Possible next steps
- Share some API minor utils for the access log.
- Add a GUID to the token to allow a "session" control using a shared
resource (like Redis), to invalidate the cookie after a few minutes of
inactivity.

## Notes on secret generation

The secret key to be saved to the GitHub should be generated using some
strong random generator, for instance, executing the `openssl rand
-base64 32`. The value should be sent to the PODs as base64 which means
it should be added to GitHub secrets as a base64 string.
  • Loading branch information
andrewsignori-aot authored Feb 20, 2025
1 parent 147bbc0 commit dddb903
Show file tree
Hide file tree
Showing 37 changed files with 471 additions and 67 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/env-setup-deploy-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ jobs:
ZEEBE_CLIENT_SECRET: ${{ secrets.ZEEBE_CLIENT_SECRET }}
CAMUNDA_OAUTH_URL: ${{ secrets.CAMUNDA_OAUTH_URL }}
ZEEBE_GRPC_WORKER_LONGPOLL_SECONDS: ${{ secrets.ZEEBE_GRPC_WORKER_LONGPOLL_SECONDS }}
QUEUE_DASHBOARD_TOKEN_SECRET: ${{ secrets.QUEUE_DASHBOARD_TOKEN_SECRET }}
QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS: ${{ vars.QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS }}
CAS_BASE_URL: ${{ secrets.CAS_BASE_URL }}
CAS_CLIENT_ID: ${{ secrets.CAS_CLIENT_ID }}
CAS_CLIENT_SECRET: ${{ secrets.CAS_CLIENT_SECRET }}
Expand Down
6 changes: 4 additions & 2 deletions configs/env-example
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_STANDALONE_MODE=true
QUEUE_CONSUMERS_PORT=
QUEUE_DASHBOARD_PASSWORD=
QUEUE_DASHBOARD_USER=
QUEUE_PREFIX={sims-local}
# The below secret should be set with any valid base 64 string for local development.
QUEUE_DASHBOARD_TOKEN_SECRET=
QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS=3600
QUEUE_DASHBOARD_BASE_URL=http://localhost:3010

#ClamAV Properties
CLAMAV_HOST=127.0.0.1
Expand Down
7 changes: 6 additions & 1 deletion devops/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export FORMIO_SOURCE_REPO_TAG := $(or $(FORMIO_SOURCE_REPO_TAG), v4.3.0)
export FORMIO_ROOT_EMAIL := $(or ${FORMIO_ROOT_EMAIL}, [email protected])
export MONGODB_URI := $(or ${MONGODB_URI}, $$MONGODB_URI)
export QUEUE_PREFIX := $(or $(QUEUE_PREFIX), {sims-local})
export QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS := $(or $(QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS), 3600)

export MAX_WAIT=300 # Maximum wait time in seconds.
export WAIT_TIME=0 # Initialize wait time to zero.
Expand Down Expand Up @@ -192,6 +193,8 @@ init-secrets:
-p ZEEBE_CLIENT_SECRET=$(ZEEBE_CLIENT_SECRET) \
-p CAMUNDA_OAUTH_URL=$(CAMUNDA_OAUTH_URL) \
-p ZEEBE_GRPC_WORKER_LONGPOLL_SECONDS=$(ZEEBE_GRPC_WORKER_LONGPOLL_SECONDS) \
-p QUEUE_DASHBOARD_TOKEN_SECRET=$(QUEUE_DASHBOARD_TOKEN_SECRET) \
-p QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS=$(QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS) \
-p CAS_BASE_URL=$(CAS_BASE_URL) \
-p CAS_CLIENT_ID=$(CAS_CLIENT_ID) \
-p CAS_CLIENT_SECRET=$(CAS_CLIENT_SECRET) \
Expand Down Expand Up @@ -335,6 +338,7 @@ deploy-api:
test -n "$(ATBC_ENDPOINT)"
test -n "$(API)"
test -n "$(API_SECRET_NAME)"
test -n "$(HOST)"
test -n "$(SWAGGER_NAME)"
test -n "$(QUEUE_PREFIX)"
test -n "$(API_PORT)"
Expand Down Expand Up @@ -370,7 +374,8 @@ deploy-api:
-p TLS_CERTIFICATE=$(TLS_CERTIFICATE) \
-p TLS_KEY=$(TLS_KEY) \
-p TLS_CA_CERTIFICATE=$(TLS_CA_CERTIFICATE) \
-p ALLOW_BETA_USERS_ONLY=$(ALLOW_BETA_USERS_ONLY) \
-p ALLOW_BETA_USERS_ONLY=$(ALLOW_BETA_USERS_ONLY) \
-p QUEUE_DASHBOARD_BASE_URL=https://$(HOST) \
| oc -n $(NAMESPACE) apply -f -
$(call rollout_and_wait,deployment/$(API))

Expand Down
18 changes: 18 additions & 0 deletions devops/openshift/api-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ objects:
name: ${REDIS_SECRET_NAME}
- name: QUEUE_PREFIX
value: "${QUEUE_PREFIX}"
- name: QUEUE_DASHBOARD_TOKEN_SECRET
valueFrom:
secretKeyRef:
key: ${QUEUE_DASHBOARD_TOKEN_SECRET_KEY}
name: ${API_SECRET_NAME}
- name: QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS
valueFrom:
secretKeyRef:
key: ${QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS_KEY}
name: ${API_SECRET_NAME}
- name: QUEUE_DASHBOARD_BASE_URL
value: "${QUEUE_DASHBOARD_BASE_URL}"
- name: ZEEBE_ADDRESS
valueFrom:
secretKeyRef:
Expand Down Expand Up @@ -509,6 +521,12 @@ parameters:
value: redis-password
- name: QUEUE_PREFIX
required: true
- name: QUEUE_DASHBOARD_TOKEN_SECRET_KEY
value: queue-dashboard-token-secret
- name: QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS_KEY
value: queue-dashboard-token-expiration-seconds
- name: QUEUE_DASHBOARD_BASE_URL
required: true
- name: APPLICATION_ARCHIVE_DAYS
require: true
- name: S3_ACCESS_KEY_ID_KEY
Expand Down
22 changes: 9 additions & 13 deletions devops/openshift/init-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ objects:
zeebe-client-secret: ${ZEEBE_CLIENT_SECRET}
camunda-oauth-url: ${CAMUNDA_OAUTH_URL}
zeebe-grpc-worker-longpoll-seconds: ${ZEEBE_GRPC_WORKER_LONGPOLL_SECONDS}
queue-dashboard-user: ${QUEUE_DASHBOARD_USER}
queue-dashboard-password: ${QUEUE_DASHBOARD_PASSWORD}
queue-dashboard-token-secret: ${QUEUE_DASHBOARD_TOKEN_SECRET}
queue-dashboard-token-expiration-seconds: ${QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS}
cas-base-url: ${CAS_BASE_URL}
cas-client-id: ${CAS_CLIENT_ID}
cas-client-secret: ${CAS_CLIENT_SECRET}
Expand Down Expand Up @@ -198,17 +198,13 @@ parameters:
required: true
description: |
Zeebe worker long pooling seconds interval to wait for jobs to be activated.
- name: QUEUE_DASHBOARD_USER
description: Bull board UI user
displayName: User for queue monitoring dashboard
generate: expression
from: "[a-zA-Z0-9]{8}"
required: true
- name: QUEUE_DASHBOARD_PASSWORD
description: Bull board UI password
displayName: Password for queue monitoring dashboard
generate: expression
from: "[a-zA-Z0-9]{32}"
- name: QUEUE_DASHBOARD_TOKEN_SECRET
description: Bull board token secret for token generation and validation.
displayName: Queue dashboard token secret
required: true
- name: QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS
description: Bull board token expiration(seconds).
displayName: Queue dashboard token expiration seconds.
required: true
- name: CAS_BASE_URL
description: CAS base URL used in CAS API requests.
Expand Down
17 changes: 7 additions & 10 deletions devops/openshift/queue-consumers-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,13 @@ objects:
secretKeyRef:
key: ${REDIS_PASSWORD_KEY}
name: ${REDIS_SECRET_NAME}
- name: QUEUE_DASHBOARD_USER
valueFrom:
secretKeyRef:
key: ${DASHBOARD_USER_KEY}
name: ${QUEUE_CONSUMERS_SECRET_NAME}
- name: QUEUE_DASHBOARD_PASSWORD
- name: QUEUE_PREFIX
value: "${QUEUE_PREFIX}"
- name: QUEUE_DASHBOARD_TOKEN_SECRET
valueFrom:
secretKeyRef:
key: ${DASHBOARD_PASSWORD_KEY}
key: ${QUEUE_DASHBOARD_TOKEN_SECRET_KEY}
name: ${QUEUE_CONSUMERS_SECRET_NAME}
- name: QUEUE_PREFIX
value: "${QUEUE_PREFIX}"
- name: ZEEBE_ADDRESS
valueFrom:
secretKeyRef:
Expand Down Expand Up @@ -433,13 +428,15 @@ parameters:
- name: DASHBOARD_USER_KEY
value: queue-dashboard-user
- name: PATH
value: "/admin/queues/"
value: "/admin/queues"
- name: PORT
required: true
- name: HOST_NAME
required: true
- name: QUEUE_PREFIX
required: true
- name: QUEUE_DASHBOARD_TOKEN_SECRET_KEY
value: queue-dashboard-token-secret
- name: IS_FULLTIME_ALLOWED
required: true
- name: ZONE_B_SFTP_SECRET_NAME
Expand Down
6 changes: 4 additions & 2 deletions sources/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ services:
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_STANDALONE_MODE=${REDIS_STANDALONE_MODE}
- QUEUE_PREFIX=${QUEUE_PREFIX}
- QUEUE_DASHBOARD_TOKEN_SECRET=${QUEUE_DASHBOARD_TOKEN_SECRET}
- QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS=${QUEUE_DASHBOARD_TOKEN_EXPIRATION_SECONDS}
- QUEUE_DASHBOARD_BASE_URL=${QUEUE_DASHBOARD_BASE_URL}
- KEYCLOAK_AUTH_URL=${KEYCLOAK_AUTH_URL}
- KEYCLOAK_REALM=${KEYCLOAK_REALM}
- KEYCLOAK_CLIENT_STUDENT=${KEYCLOAK_CLIENT_STUDENT}
Expand Down Expand Up @@ -147,9 +150,8 @@ services:
- CLAMAV_PORT=${CLAMAV_PORT}
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_STANDALONE_MODE=${REDIS_STANDALONE_MODE}
- QUEUE_DASHBOARD_USER=${QUEUE_DASHBOARD_USER}
- QUEUE_DASHBOARD_PASSWORD=${QUEUE_DASHBOARD_PASSWORD}
- QUEUE_PREFIX=${QUEUE_PREFIX}
- QUEUE_DASHBOARD_TOKEN_SECRET=${QUEUE_DASHBOARD_TOKEN_SECRET}
- INSTITUTION_REQUEST_FOLDER=${INSTITUTION_REQUEST_FOLDER}
- INSTITUTION_RESPONSE_FOLDER=${INSTITUTION_RESPONSE_FOLDER}
- GC_NOTIFY_URL=${GC_NOTIFY_URL}
Expand Down
1 change: 1 addition & 0 deletions sources/packages/backend/apps/api/src/auth/roles.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum Role {
AESTEditCASSupplierInfo = "aest-edit-cas-supplier-info",
AESTCASInvoicing = "aest-cas-invoicing",
AESTBypassStudentRestriction = "aest-bypass-student-restriction",
AESTQueueDashboardAdmin = "aest-queue-dashboard-admin",
StudentAddRestriction = "student-add-restriction",
StudentResolveRestriction = "student-resolve-restriction",
StudentUploadFile = "student-upload-file",
Expand Down
1 change: 1 addition & 0 deletions sources/packages/backend/apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ async function bootstrap() {
app.enableCors({
exposedHeaders: "Content-Disposition",
origin: allowAnyOrigin,
credentials: true,
});

// pipes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe("ConfigController(e2e)-getConfig", () => {
MAXIMUM_IDLE_TIME_FOR_WARNING_INSTITUTION: "3600",
MAXIMUM_IDLE_TIME_FOR_WARNING_AEST: "3600",
APP_ENV: "production",
QUEUE_DASHBOARD_BASE_URL: "http://some-absolute-url",
};

beforeAll(async () => {
Expand Down Expand Up @@ -67,6 +68,7 @@ describe("ConfigController(e2e)-getConfig", () => {
maximumIdleTimeForWarningAEST:
+fakeEnvVariables.MAXIMUM_IDLE_TIME_FOR_WARNING_AEST,
appEnv: fakeEnvVariables.APP_ENV,
queueDashboardURL: `${fakeEnvVariables.QUEUE_DASHBOARD_BASE_URL}/admin/queues`,
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Public } from "../../auth/decorators/public.decorator";
import { ConfigService } from "@sims/utilities/config";
import BaseController from "../BaseController";
import { ConfigAPIOutDTO } from "./models/config.dto";
import { BULL_BOARD_ROUTE } from "@sims/services/constants";

@Controller("config")
@ApiTags("config")
Expand Down Expand Up @@ -38,6 +39,7 @@ export class ConfigController extends BaseController {
maximumIdleTimeForWarningAEST:
this.configService.maximumIdleTimeForWarningAEST,
appEnv: this.configService.appEnv,
queueDashboardURL: `${process.env.QUEUE_DASHBOARD_BASE_URL}/${BULL_BOARD_ROUTE}`,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export class ConfigAPIOutDTO {
maximumIdleTimeForWarningInstitution: number;
maximumIdleTimeForWarningAEST: number;
appEnv: string;
queueDashboardURL: string;
}

export class AuthConfigAPIOutDTO {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { BadRequestException, Controller, Get, Put } from "@nestjs/common";
import {
BadRequestException,
Controller,
Delete,
Get,
HttpStatus,
Post,
Put,
Res,
} from "@nestjs/common";
import { UserService } from "../../services";
import BaseController from "../BaseController";
import { UserToken } from "../../auth/decorators/userToken.decorator";
Expand All @@ -10,11 +19,22 @@ import {
AllowAuthorizedParty,
Groups,
RequiresUserAccount,
Roles,
} from "../../auth/decorators";
import { ApiTags, ApiUnprocessableEntityResponse } from "@nestjs/swagger";
import { UserControllerService } from "..";
import { ApiProcessError, ClientTypeBaseRoute } from "../../types";
import { MISSING_USER_INFO } from "../../constants";
import { CookieOptions, Response } from "express";
import { QueueDashboardToken } from "@sims/auth/services";
import {
QUEUE_DASHBOARD_AUDIENCE,
QUEUE_DASHBOARD_AUTH_COOKIE,
QUEUE_DASHBOARD_ISSUER,
} from "@sims/auth/constants";
import { ConfigService } from "@sims/utilities/config";
import { JwtService } from "@nestjs/jwt";
import { Role } from "../../auth";

@AllowAuthorizedParty(AuthorizedParties.aest)
@Groups(UserGroups.AESTUser)
Expand All @@ -24,6 +44,8 @@ export class UserAESTController extends BaseController {
constructor(
private readonly userService: UserService,
private readonly userControllerService: UserControllerService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {
super();
}
Expand Down Expand Up @@ -72,4 +94,55 @@ export class UserAESTController extends BaseController {
userToken.lastName,
);
}

/**
* Allows a token creation to provide access to the queues admin
* for an already authorized users with a role that allow the access.
*/
@Post("queue-admin-token-exchange")
@Roles(Role.AESTQueueDashboardAdmin)
async queueAdminTokenExchange(
@UserToken() userToken: IUserToken,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
// Exchange token creation.
const queueDashboardToken = {
iss: QUEUE_DASHBOARD_ISSUER,
sub: userToken.userName,
aud: QUEUE_DASHBOARD_AUDIENCE,
} as QueueDashboardToken;
const tokenExpiresIn =
this.configService.queueDashboardAccess.tokenExpirationSeconds;
const signedToken = this.jwtService.sign(queueDashboardToken, {
secret: this.configService.queueDashboardAccess.tokenSecret,
expiresIn: tokenExpiresIn,
});
// Session cookie creation to store the exchange token.
// This cookie is removed when the browser is closed because it does not have an expiration set.
const cookieOptions: CookieOptions = {
httpOnly: true,
secure: true,
sameSite: "strict",
};
if (process.env.NODE_ENV !== "production") {
cookieOptions.secure = false;
cookieOptions.sameSite = "lax";
}
// Save the exchange token in a cookie to sent and stored in the client.
response.cookie(QUEUE_DASHBOARD_AUTH_COOKIE, signedToken, cookieOptions);
response.status(HttpStatus.NO_CONTENT).send();
}

/**
* Clear the cookie that stores the queues admin access token.
* Useful to remove the access to the queues admin when the user is no longer authorized.
*/
@Delete("queue-admin-token-exchange")
@Roles(Role.AESTQueueDashboardAdmin)
async removeAdminTokenExchange(
@Res({ passthrough: true }) response: Response,
): Promise<void> {
response.clearCookie(QUEUE_DASHBOARD_AUTH_COOKIE);
response.status(HttpStatus.NO_CONTENT).send();
}
}
Loading

0 comments on commit dddb903

Please sign in to comment.