Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions sample.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
NODE_ENV=production
SERVICE_NAME=folder-creation

# Process-wide timezone.
# TZ=Europe/Stockholm

RABBITMQ_HOSTNAME=localhost
RABBITMQ_USERNAME=guest
RABBITMQ_PASSWORD=guest
Expand Down
10 changes: 9 additions & 1 deletion src/config/validateEnv.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { cleanEnv, str, bool, port } from 'envalid';
import { cleanEnv, str, bool, port, makeValidator } from 'envalid';

const timeZone = makeValidator<string>((value) => {
// Fail on invalid timezone identifiers rather than silently using another timezone.
new Intl.DateTimeFormat('en-GB', { timeZone: value });

return value;
});

function validateEnv() {
cleanEnv(process.env, {
RABBITMQ_HOSTNAME: str(),
RABBITMQ_USERNAME: str(),
RABBITMQ_PASSWORD: str(),
// NOTE: All variables are optional except RabbitMQ connection ones.
TZ: timeZone({ default: undefined }),
KAFKA_CLIENTID: str({ default: undefined }),
KAFKA_BROKERS: str({ default: undefined }),
KAFKA_TOPIC: str({ default: undefined }),
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ async function bootstrap() {
logger.logInfo('Server information: ', {
nodeVersion: process.version,
env: process.env.NODE_ENV,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});

const PORT = process.env.PORT || 4010;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,19 @@ describe('OneIdentityIntegrationQueueConsumer', () => {
const axiosError = new Error('Axios Error');
const mockResponse = {
status: 400,
statusText: 'Bad Request',
headers: { 'content-type': 'application/json' },
data: { message: 'Bad Request' },
};

Object.assign(axiosError, {
isAxiosError: true,
response: mockResponse,
config: {
method: 'post',
baseURL: 'https://one-identity.example.org/api',
url: '/entity/ESET',
},
});

(isAxiosError as unknown as jest.Mock).mockReturnValueOnce(true);
Expand All @@ -159,8 +165,14 @@ describe('OneIdentityIntegrationQueueConsumer', () => {
{
type,
message,
request: {
method: 'POST',
baseURL: 'https://one-identity.example.org/api',
url: '/entity/ESET',
},
response: {
status: mockResponse.status,
statusText: mockResponse.statusText,
headers: mockResponse.headers,
data: mockResponse.data,
},
Expand Down Expand Up @@ -222,13 +234,19 @@ describe('OneIdentityIntegrationQueueConsumer', () => {
const axiosError = new Error('Axios Error');
const mockResponse = {
status: 500,
statusText: 'Internal Server Error',
headers: { 'content-type': 'application/json' },
data: { message: 'Internal Server Error' },
};

Object.assign(axiosError, {
isAxiosError: true,
response: mockResponse,
config: {
method: 'put',
baseURL: 'https://one-identity.example.org/api',
url: '/script/SCPEJAllowance',
},
});

(isVisitMessage as unknown as jest.Mock).mockReturnValue(true);
Expand All @@ -248,8 +266,14 @@ describe('OneIdentityIntegrationQueueConsumer', () => {
{
type,
message,
request: {
method: 'PUT',
baseURL: 'https://one-identity.example.org/api',
url: '/script/SCPEJAllowance',
},
response: {
status: mockResponse.status,
statusText: mockResponse.statusText,
headers: mockResponse.headers,
data: mockResponse.data,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { logger } from '@user-office-software/duo-logger';
import { ConsumerCallback } from '@user-office-software/duo-message-broker';
import { isAxiosError } from 'axios';

import { QueueConsumer } from '../QueueConsumer';
import { syncProposalAndMembersToOneIdentityHandler } from './consumerCallbacks/syncProposalAndMembersToOneIdentityHandler';
import { syncVisitToOneIdentityHandler } from './consumerCallbacks/syncVisitToOneIdentityHandler';
import { getAxiosErrorDetails } from './utils/getAxiosErrorDetails';
import { isVisitMessage } from './utils/isVisitMessage';
import { validateProposalMessage } from './utils/validateProposalMessage';
import { Event } from '../../../models/Event';
Expand Down Expand Up @@ -83,15 +83,13 @@ export class OneIdentityIntegrationQueueConsumer extends QueueConsumer {
message,
});
} catch (error) {
const response = extractAxiosErrorResponse(error);

logger.logException(
'Error while handling message in OneIdentityIntegrationQueueConsumer',
error,
{
type,
message,
response,
...getAxiosErrorDetails(error),
}
);

Expand All @@ -100,15 +98,3 @@ export class OneIdentityIntegrationQueueConsumer extends QueueConsumer {
}
};
}

function extractAxiosErrorResponse(error: unknown) {
if (isAxiosError(error)) {
return {
status: error.response?.status,
headers: error.response?.headers,
data: error.response?.data,
};
}

return undefined;
}
48 changes: 38 additions & 10 deletions src/queue/consumers/oneidentity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
## Functional Specification

### Purpose
The handler manages site and system access in One Identity based on visit creation and deletion events for science users.
The handler manages site and system access in One Identity based on visit creation, update and deletion events for science users.

### Process Overview
- When a visit is created, both site access and system access are provisioned in One Identity
- When a visit is updated, each access period is reconciled independently and missing active access records are created
- When a visit is deleted, both site access and system access are cancelled in One Identity
- Only science users (users with `CCC_EmployeeSubType === ESSSCIENCEUSER`) are processed

### Access Types
- **Site Access**: Physical access to facility for the exact visit duration
- **System Access**: Digital access to systems, extends beyond the visit end date by a configurable number of days (default: 30)
- **System Access**: Digital access to systems starting at the current time when provisioned, extending beyond the visit end date by a configurable number of days (default: 30)

### Key Relationships
- System access is linked to site access via `CustomProperty04` which stores the site access UID
- Both site and system access store the visit ID in `CustomProperty04`
- Both access types are identified by specific roles in `PersonWantsOrgRole` enum
- Proposal's short code is stored in `CustomProperty04` of the system access record

## Process Flow Chart

Expand Down Expand Up @@ -86,21 +86,49 @@ The handler manages site and system access in One Identity based on visit creati

## Key Implementation Details

### Timezone Configuration
- Set `TZ=Europe/Stockholm` in the dedicated OIM connector service/container environment before starting Node, or in its `.env` file

### System Access Duration
- Extends beyond visit by `ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS` (default: 30 days)

### Access Creation
- Site access matches exact visit dates
- System access starts from the visit start date and extends beyond the visit end date by a configurable number of days (default: 30)
- System access links to site access via `CustomProperty04`
- Site access starts at local midnight on the visit start date and ends at local midnight after the visit end date, covering both visit days in full
- System access starts at the current instant when provisioned, serialized as a UTC ISO timestamp without rounding to midnight; it ends at local midnight after the visit end date plus the configured calendar-day extension (default: 30)
- Site and system access share the visit ID in `CustomProperty04`

### Access Updates
- Access records are matched by role and visit ID in `CustomProperty04`; both `Aborted` and `Unsubscribed` records are excluded

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When does a Record gets Aborted or Unsubscribed. Could we write here for better understanding.

- Site access is updated only when its local-day start or end boundary differs from the visit's required validity
- System access is updated only when its expiry differs from local midnight after the visit end date plus the configured calendar-day extension; its start is set to the current instant when that update is processed
- The system start time is not compared against the current time; a correct existing system expiry preserves the original start, even if site access needs updating
- Missing active site or system access is created independently, without recreating the other access
- Both access periods must match before all access writes are skipped, allowing retries to repair system access after a successful site write followed by a failed system write

### Daily Allowance Synchronization
- Allowance eligibility requires only the answer with natural key `daily_allowance_is_approved` to be boolean `true`
- `request_daily_allowance` is ignored: a User Officer can approve allowance even when the visitor did not request it
- Visit creation upserts an eligible allowance; otherwise it skips allowance synchronization
- If the `daily_allowance_is_approved` key is absent, skip allowance synchronization for all visit events for backward compatibility; existing allowances remain untouched and normal visit access processing continues
- When the approval key is present, visit updates upsert an allowance for boolean `true`; any other value issues an idempotent delete by visitor and visit ID, even when visit dates are unchanged
- A present approval answer with a missing/non-true value is distinct from an absent key; the previous allowance state is not required
- Withdrawing a request does not remove an approved allowance; withdrawing approval triggers deletion on the next visit update
- When the approval key is present, visit deletion issues an idempotent allowance delete regardless of its value, before cancelling visit access
- Approved PEJ allowance upserts send the visit's local start and end calendar dates as `YYYY-MM-DD`, on both visit creation and update
- PEJ's `GuestValidityFrom` and `GuestValidityTo` are inclusive and cover the full day in the organization's configured timezone; do not add a day to the allowance end date
- With `TZ=Europe/Stockholm`, a visit on 28–29 October 2026 has allowance validity `2026-10-28` through `2026-10-29`. Site access starts at `2026-10-27T23:00:00.000Z` and ends at `2026-10-29T23:00:00.000Z` (30 October at Swedish midnight)
- Allowance deletion continues to send empty date parameters

### Access Cancellation
- System access cancellation depends on finding the parent site access first
- Both must be cancelled
- Site and system access are matched independently by role and visit ID
- All matching access records are cancelled, except those already `Aborted` or `Unsubscribed`
- Missing or already-cancelled access is treated as successful cleanup, so retries can finish partial deletions
- Actual API cancellation failures still propagate for retry

### Error Handling
- Proper error messages when person or access records are not found
- Always performs logout in finally block to ensure clean session management
- HTTP error logs include the request method, base URL and path, plus the response status, headers and full body (without request credentials)

---

Expand Down Expand Up @@ -226,4 +254,4 @@ PROPOSAL_ACCEPTED PROPOSAL_UPDATED
### Error Handling
- **Proposal Creation Failure**: If creating a new proposal (`ESet`) in One Identity fails during a `PROPOSAL_ACCEPTED` event, an error is thrown, and the process is halted.
- **User Not Found**: If any users listed in the proposal message (proposer or members) cannot be found in One Identity, an error is logged. The process continues with the users that were found.
- **Logout Guarantee**: The One Identity session is always closed in a `finally` block, ensuring resources are released even if errors occur during the synchronization process.
- **Logout Guarantee**: The One Identity session is always closed in a `finally` block, ensuring resources are released even if errors occur during the synchronization process.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const mockOneIdentity: jest.Mocked<Omit<ESSOneIdentity, 'oneIdentityApi'>> = {
getPersonWantsOrg: jest.fn(),
upsertPersonWantsOrg: jest.fn(),
cancelPersonWantsOrg: jest.fn(),
syncPEJAllowance: jest.fn(),
hasPersonSiteAccessToProposal: jest.fn(),
};

Expand Down
Loading
Loading