Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
122 changes: 122 additions & 0 deletions d2e/plans/audit-log-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Disclaimer audit endpoint and logout-flow plan

## Goal

Replace the portal's upstream `POST /trex/log` disclaimer audit request with a D2E-owned `POST /system-portal/audit/log` endpoint. Preserve disclaimer audit recording for authenticated tenant viewers while ensuring that audit failures never prevent either disclaimer acceptance or OIDC logout.

## Current flow and cause

`plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx` currently sends `{ response: "ACCEPTED" | "DECLINED" }` through `api.trex.logResponse()`, which posts to `trex/log`. Both handlers await this optional audit request. In `handleLogout`, navigation to the portal's existing `/logout` route happens only after the await, so a failed request prevents the OIDC logout route from mounting.

The current `/logout` route is the established path to `oidcLogout()` and its configured OIDC end-session endpoint. It should remain the logout mechanism.

## 1. Add a D2E-owned portal audit endpoint

### New files

Create `plugins/functions/portal/src/audit/` with:

- `audit.controller.ts`
- Use `@Middleware(RequestContextMiddleware)` and `@Controller("system-portal/audit")`.
- Add `@Post("log")` accepting a body with a `response` limited to `ACCEPTED` or `DECLINED`.
- Obtain the authenticated subject from `RequestContextService.getAuthToken()?.sub`; do not accept a user identifier from the browser payload.
- Delegate the audit write to `AuditService.logDisclaimerResponse(userId, response)`.
- Return a successful response once the audit record is persisted.

- `audit.service.ts`
- Provide `logDisclaimerResponse(userId, response)`.
- Use the portal database conventions and an explicit persistence model to record the authenticated user, response value, and creation time.
- Follow the existing request-context pattern used by portal services: derive the user identity from the decoded token subject (`sub`).

- `audit.module.ts`
- Register `AuditController`, `AuditService`, and `RequestContextService`.
- Import the database and transaction modules required by the chosen persistence implementation, mirroring feature modules such as `src/feature/feature.module.ts`.

### Application registration

Update `plugins/functions/portal/src/app.module.ts`:

- Import `AuditModule`.
- Add it to the root module `imports` list.

### Data model and migration

Before implementation, inspect the portal database migration/entity conventions and create the smallest dedicated audit persistence model required for disclaimer responses. The record must be keyed by authenticated user ID and contain the response value and timestamp. It must not reuse the upstream Trex endpoint or accept caller-supplied identity data.

## 2. Authorize the new endpoint

Update `plugins/functions/package.json` in both authorization structures:

1. Add `portal.audit.log` to the `TENANT_VIEWER` role's granted scopes.
2. Add a route-scope entry:

```json
{
"path": "^/system-portal/audit/log$",
"scopes": ["portal.audit.log"],
"httpMethods": ["POST"]
}
```

This follows the functions gateway model: scopes are enforced from the manifest rather than by role decorators in the Danet controller. Tenant viewers, including the deployment's appropriately mapped authenticated portal users, receive only this narrowly scoped audit permission.

## 3. Move the portal client to the new endpoint

### System Portal API client

Update `plugins/ui/apps/portal/src/axios/system-portal.ts`:

- Import `LogResponseType` from the portal constants.
- Add `logAuditResponse(response: LogResponseType)` to `SystemPortal`.
- Send `POST` with `{ response }` to `system-portal/audit/log` using the existing `SYSTEM_PORTAL_URL` base URL.

### Remove the Trex client dependency

Update `plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx`:

- Change `logUserResponse()` to call `api.systemPortal.logAuditResponse(logResponse)` rather than `api.trex.logResponse(logResponse)`.
- Preserve the existing feature flag behavior (`REACT_APP_LOG_DISCLAIMER`) unless tests establish that it is obsolete.
- Catch and report audit-request failures within `logUserResponse()` without rethrowing, so audit remains best-effort.
- Remove the now-unused `api.trex.logResponse()` path from this dialog. Once all repository references are confirmed absent, remove the obsolete `Trex.logResponse` method and its `LogResponseType` import from `plugins/ui/apps/portal/src/axios/trex.ts`.

## 4. Guarantee logout routing

In `DisclaimerDialog.tsx`:

- Keep `handleAccept` responsible for updating disclaimer state and local storage, then make its audit submission best-effort.
- Make `handleLogout` initiate the declined-response audit without allowing an audit error to interrupt control flow, then unconditionally execute:

```ts
navigate(config.ROUTES.logout);
```

The resulting route remains `/logout` -> `Logout` -> `oidcLogout()` -> the deployment's configured OIDC end-session endpoint. No direct Azure-specific endpoint construction is required.

## 5. Tests and verification

### Backend

- Add controller/service tests in `plugins/functions/portal/src/audit/` covering accepted and declined payloads, authenticated subject propagation, and audit persistence.
- Verify the route is registered at `POST /system-portal/audit/log`.
- Verify the function authorization manifest grants `portal.audit.log` to `TENANT_VIEWER` and requires it only for POST to the exact audit path.
- Exercise the endpoint through the running D2E edge runtime as an authenticated tenant viewer, confirming success and persisted/logged audit data; verify an unauthenticated request is rejected.

### Portal UI

- Add or update a focused `DisclaimerDialog` test to verify Accept submits `ACCEPTED` to `api.systemPortal.logAuditResponse` and continues when it rejects.
- Verify Logout submits `DECLINED`, navigates to `config.ROUTES.logout` when the audit request succeeds, rejects, and remains unresolved, and never invokes `api.trex.logResponse`.
- Run the portal type check and relevant unit tests.
- Build the portal, deploy the built resources to the served D2E route, and verify the real disclaimer flow: Accept closes the disclaimer; Logout reaches the OIDC session-end redirect even if the audit endpoint is unavailable.

## Files expected to change

- `plugins/functions/package.json`
- `plugins/functions/portal/src/app.module.ts`
- `plugins/functions/portal/src/audit/audit.controller.ts` (new)
- `plugins/functions/portal/src/audit/audit.service.ts` (new)
- `plugins/functions/portal/src/audit/audit.module.ts` (new)
- Portal audit DTO/entity/repository/migration files determined by the existing portal persistence conventions
- `plugins/ui/apps/portal/src/axios/system-portal.ts`
- `plugins/ui/apps/portal/src/axios/trex.ts` if it has no remaining `logResponse` callers
- `plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx`
- Corresponding backend and portal tests
10 changes: 10 additions & 0 deletions plugins/functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@
"portal.filterScope.read",
"portal.dataset.release.list.read",
"portal.config.read",
"portal.audit.log",
"codesuggestion.tenantview.read",
"mcpchat.tenantview.read",
"d2e.webapi.public",
Expand Down Expand Up @@ -1399,6 +1400,15 @@
"GET"
]
},
{
"path": "^/system-portal/audit/log$",
"scopes": [
"portal.audit.log"
],
"httpMethods": [
"POST"
]
},
{
"path": "^/system-portal/dataset$",
"scopes": [
Expand Down
2 changes: 2 additions & 0 deletions plugins/functions/portal/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from "@danet/core";
import { RequestContextMiddleware } from "./common/request-context.middleware.ts";
import { AuditModule } from "./audit/audit.module.ts";
import { ConfigModule } from "./config/config.module.ts";
import { DatabaseModule } from "./database/module.ts";
import { DatasetModule } from "./dataset/dataset.module.ts";
Expand All @@ -17,6 +18,7 @@ import { GitDashboardModule } from "./git-dashboards/git-dashboards.module.ts";
@Module({
controllers: [],
imports: [
AuditModule,
TenantModule,
SystemModule,
FeatureModule,
Expand Down
14 changes: 14 additions & 0 deletions plugins/functions/portal/src/audit/audit.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Body, Controller, Middleware, Post } from "@danet/core";
import { RequestContextMiddleware } from "../common/request-context.middleware.ts";
import { AuditService } from "./audit.service.ts";

@Middleware(RequestContextMiddleware)
@Controller("system-portal/audit")
export class AuditController {
constructor(private readonly auditService: AuditService) {}

@Post("log")
logDisclaimerResponse(@Body() body: { response: string }) {
this.auditService.logDisclaimerResponse(body.response);
}
}
Comment on lines +1 to +14
10 changes: 10 additions & 0 deletions plugins/functions/portal/src/audit/audit.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from "@danet/core";
import { RequestContextService } from "../common/request-context.service.ts";
import { AuditController } from "./audit.controller.ts";
import { AuditService } from "./audit.service.ts";

@Module({
controllers: [AuditController],
injectables: [RequestContextService, AuditService],
})
export class AuditModule {}
20 changes: 20 additions & 0 deletions plugins/functions/portal/src/audit/audit.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { assertEquals } from "@std/assert";
import { RequestContextService } from "../common/request-context.service.ts";
import { AuditService } from "./audit.service.ts";

Deno.test("logs a disclaimer response for the authenticated user", () => {
const requestContextService = new RequestContextService();
requestContextService.setAuthToken({ sub: "user-123" });
const service = new AuditService(requestContextService);
const messages: unknown[][] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => messages.push(args);

try {
service.logDisclaimerResponse("ACCEPTED");
} finally {
console.log = originalLog;
}

assertEquals(messages, [["Disclaimer response", { userId: "user-123", response: "ACCEPTED" }]]);
});
14 changes: 14 additions & 0 deletions plugins/functions/portal/src/audit/audit.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Injectable, SCOPE } from "@danet/core";
import { RequestContextService } from "../common/request-context.service.ts";

@Injectable({ scope: SCOPE.REQUEST })
export class AuditService {
constructor(private readonly requestContextService: RequestContextService) {}

logDisclaimerResponse(response: string) {
const userId = this.requestContextService.getAuthToken()?.sub;

// TODO: Persist disclaimer audit records when a portal audit data model is available.
console.log("Disclaimer response", { userId, response });
}
}
24 changes: 24 additions & 0 deletions plugins/ui/apps/portal/src/axios/system-portal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { SystemPortal } from "./system-portal";
import { LogResponseType } from "../constant";
import { request } from "./request";

jest.mock("./request", () => ({
request: jest.fn(),
}));

const mockRequest = request as jest.MockedFunction<typeof request>;

describe("SystemPortal.logAuditResponse", () => {
it("posts the disclaimer response to the D2E-owned audit route", () => {
const systemPortal = new SystemPortal();

systemPortal.logAuditResponse(LogResponseType.DECLINED);

expect(mockRequest).toHaveBeenCalledWith({
baseURL: "system-portal/",
url: "audit/log",
method: "POST",
data: { response: LogResponseType.DECLINED },
});
});
});
11 changes: 10 additions & 1 deletion plugins/ui/apps/portal/src/axios/system-portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,19 @@ import {
ViewerCodeQuery,
ViewerCodeWithQueries,
} from "../types";
import { ConfigTypes } from "../constant";
import { ConfigTypes, LogResponseType } from "../constant";
const SYSTEM_PORTAL_URL = "system-portal/";

export class SystemPortal {
public logAuditResponse(response: LogResponseType) {
return request({
baseURL: SYSTEM_PORTAL_URL,
url: "audit/log",
method: "POST",
data: { response },
});
}

public getTenants() {
return request<Tenant[]>({
baseURL: SYSTEM_PORTAL_URL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import "./DisclaimerDialog.scss";

const logUserResponse = async (logResponse: LogResponseType): Promise<void> => {
if (typeof env.REACT_APP_LOG_DISCLAIMER === "string" && env.REACT_APP_LOG_DISCLAIMER.toLowerCase() === "true") {
await api.trex.logResponse(logResponse);
try {
await api.systemPortal.logAuditResponse(logResponse);
} catch {
// Disclaimer auditing must not block a user's decision.
}
}
return;
};

export const DisclaimerDialog: FC = () => {
Expand All @@ -35,11 +38,11 @@ export const DisclaimerDialog: FC = () => {
setIsDisclaimerAccepted(true);
// Persist acceptance to localStorage (only store when accepted)
saveDisclaimerToStorage(true);
await logUserResponse(LogResponseType.ACCEPTED);
void logUserResponse(LogResponseType.ACCEPTED);
}, [setIsDisclaimerAccepted]);

const handleLogout = useCallback(async () => {
await logUserResponse(LogResponseType.DECLINED);
const handleLogout = useCallback(() => {
void logUserResponse(LogResponseType.DECLINED);
navigate(config.ROUTES.logout);
}, [navigate]);

Expand Down
82 changes: 82 additions & 0 deletions trex/plans/2026-08-26-disclaimer-logout-and-log-authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Disclaimer Logout and Trex Log Authorization — Implementation Plan

**Goal:** Ensure declining the legal disclaimer always proceeds to the portal's existing OIDC logout flow, and permit any authenticated portal user to record a disclaimer response at `POST /d2e/trex/log`.

**Scope:** Two focused changes: one portal UI handler and one Trex core compatibility-route authorization change. The Trex route is not implemented in this repository: `services/trex/Dockerfile.v2` explicitly states that the D2E compatibility layer is supplied by the pinned `ghcr.io/ohdsi/trexsql` base image. Updating its guard therefore requires the upstream Trex source/package version that owns the route, followed by an image-reference update in this repository.

## Investigation findings

### Portal flow

- `plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx`
- The Logout button calls `handleLogout`.
- `handleLogout` currently awaits `logUserResponse(LogResponseType.DECLINED)` and only then calls `navigate(config.ROUTES.logout)`.
- `logUserResponse` conditionally calls `api.trex.logResponse()` whenever `REACT_APP_LOG_DISCLAIMER` is true.
- `plugins/ui/apps/portal/src/axios/trex.ts`
- `logResponse` sends `POST trex/log`; under the portal deployment base path this is `/d2e/trex/log`.
- `plugins/ui/apps/portal/src/containers/auth/Logout.tsx` and `containers/auth/oidc/oidc.ts`
- `/logout` invokes `oidcLogout()`, which invokes `oidc.logoutAsync()`.
- The configured OIDC end-session endpoint is used by the OIDC library when present.

Thus, the `POST /d2e/trex/log` request is an optional disclaimer audit event, not an OIDC endpoint. Its current 403 rejection aborts `handleLogout` before routing to `/logout`, so the OIDC session-end request never starts.

### Backend route ownership

- The portal only supplies the client request; no `/log` handler or role guard exists in `plugins/functions` or `services/trex` source.
- `services/trex/Dockerfile.v2` documents that Trex core—including its env-gated `D2E_COMPAT` routes—is provided by the pinned `ghcr.io/ohdsi/trexsql` base image, not vendored in D2E.
- The image pin is the `TREXSQL_REF` build argument at the top of `services/trex/Dockerfile.v2`.
- The reporter's `403 Forbidden: admin role required` identifies the upstream compatibility route guard as the second defect. The exact upstream file/symbol must be located in the TrexSQL source before modification; it is unavailable in this checkout.

## File structure

| Path | Change | Purpose |
| --- | --- | --- |
| `plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx` | Modify | Make declined-response logging non-blocking so logout always routes to OIDC logout. |
| `plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.test.tsx` or established colocated test path | Add | Prove Logout navigates when the audit request rejects or never settles. |
| Upstream TrexSQL D2E compatibility route source | Modify upstream | Replace the admin-only guard on `POST /log` with authenticated-user access while retaining authentication. |
| Upstream TrexSQL route test | Add/modify upstream | Prove an authenticated non-admin user can post a disclaimer response and an unauthenticated request remains denied. |
| `services/trex/Dockerfile.v2` | Modify after upstream release | Pin D2E to the TrexSQL image version containing the authorization correction. |

## Task 1: Decouple Logout from optional audit logging

**Files:**
- Modify: `plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx`
- Add or modify: focused test beside the dialog, following the portal test convention

1. Change only the declined-response path in `handleLogout` so it starts `logUserResponse(LogResponseType.DECLINED)` without awaiting it, and explicitly absorbs/report its rejection to avoid an unhandled promise rejection.
2. Call `navigate(config.ROUTES.logout)` immediately after starting the audit request.
3. Keep the Accept path unchanged: its existing accepted-state update, local-storage persistence, and audit behavior are outside the reported issue.
4. Add a focused test that mocks the Trex audit client and router navigation. Cover:
- audit logging disabled: Logout navigates to `/logout`;
- audit request rejected with 403: Logout still navigates to `/logout`;
- audit request left pending: Logout still navigates without waiting.
5. Confirm the test asserts navigation to the existing `/logout` route rather than reimplementing OIDC endpoint construction in the dialog.

## Task 2: Correct the upstream Trex compatibility-route authorization

**Files:** upstream TrexSQL source and its tests; then `services/trex/Dockerfile.v2` in this repository.

1. Obtain the exact TrexSQL source matching the pinned `TREXSQL_REF` (or the next supported release branch) and search its D2E compatibility routes for the `POST /log` registration and the `admin role required` message.
2. Read the adjacent authentication/authorization middleware to distinguish authenticated-user checks from admin-role checks and preserve request identity/audit context.
3. Change this endpoint only from admin-only to authenticated-user access. Do not make it public, and do not broaden guards on unrelated Trex routes.
4. Add upstream route-level coverage for:
- an authenticated researcher/non-admin token receives success for a valid disclaimer response;
- an authenticated admin remains permitted;
- unauthenticated requests remain rejected;
- invalid request payloads retain their current validation behavior.
5. Release or select a TrexSQL image containing that change, then update `TREXSQL_REF` in `services/trex/Dockerfile.v2` to its immutable tag and digest.

## Task 3: Verification

1. Run the focused portal dialog test and the portal TypeScript check.
2. Build the portal and verify the served UI uses the new Logout behavior through the real D2E route: with disclaimer logging enabled and `POST /d2e/trex/log` forced to return 403, clicking Logout must still start the configured OIDC end-session redirect. Capture the browser result/screenshot.
3. Run the upstream Trex route tests for the authorization change.
4. Build the D2E Trex image from the updated base reference and exercise the running `POST /d2e/trex/log` endpoint using authenticated researcher and unauthenticated requests. Verify researcher success and unauthenticated rejection.
5. Re-run the disclaimer Logout browser flow against the updated stack. Verify both outcomes: the audit POST succeeds for a researcher, and logout redirects through the OIDC end-session endpoint.

## Risks and guardrails

- **Do not await optional audit logging before logout.** A 403, timeout, or network failure must not prevent users from ending their identity-provider session.
- **Do not remove authentication on `/log`.** The intended authorization is any authenticated user, not anonymous callers.
- **Do not alter Accept behavior.** Its behavior was not reported broken and remains intentionally outside the UI fix.
- **Do not duplicate the OIDC URL in the disclaimer component.** The existing `/logout` route and `oidcLogout()` remain the single logout implementation.
Loading
Loading