diff --git a/plugins/functions/package.json b/plugins/functions/package.json index 4d790a091c..76ca683c90 100644 --- a/plugins/functions/package.json +++ b/plugins/functions/package.json @@ -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", @@ -1399,6 +1400,15 @@ "GET" ] }, + { + "path": "^/system-portal/audit/log$", + "scopes": [ + "portal.audit.log" + ], + "httpMethods": [ + "POST" + ] + }, { "path": "^/system-portal/dataset$", "scopes": [ diff --git a/plugins/functions/portal/src/app.module.ts b/plugins/functions/portal/src/app.module.ts index 9df7c2abc5..5f089bbad6 100644 --- a/plugins/functions/portal/src/app.module.ts +++ b/plugins/functions/portal/src/app.module.ts @@ -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"; @@ -17,6 +18,7 @@ import { GitDashboardModule } from "./git-dashboards/git-dashboards.module.ts"; @Module({ controllers: [], imports: [ + AuditModule, TenantModule, SystemModule, FeatureModule, diff --git a/plugins/functions/portal/src/audit/audit.controller.ts b/plugins/functions/portal/src/audit/audit.controller.ts new file mode 100644 index 0000000000..335a7abb84 --- /dev/null +++ b/plugins/functions/portal/src/audit/audit.controller.ts @@ -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); + } +} diff --git a/plugins/functions/portal/src/audit/audit.module.ts b/plugins/functions/portal/src/audit/audit.module.ts new file mode 100644 index 0000000000..ffe1d75acf --- /dev/null +++ b/plugins/functions/portal/src/audit/audit.module.ts @@ -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 {} diff --git a/plugins/functions/portal/src/audit/audit.service.test.ts b/plugins/functions/portal/src/audit/audit.service.test.ts new file mode 100644 index 0000000000..157dc66c7d --- /dev/null +++ b/plugins/functions/portal/src/audit/audit.service.test.ts @@ -0,0 +1,174 @@ +import { assertEquals, assertMatch, assertThrows } from "@std/assert"; +import { HttpException } from "@danet/core"; +import { JwtPayload } from "jsonwebtoken"; +import { RequestContextService } from "../common/request-context.service.ts"; +import { AuditService } from "./audit.service.ts"; + +const SUBJECT_PROP_ENV = "GATEWAY__IDP_SUBJECT_PROP"; + +function serviceWithClaims(payload?: Record) { + const requestContextService = new RequestContextService(); + if (payload) { + requestContextService.setAuthToken(payload as JwtPayload); + } + return new AuditService(requestContextService); +} + +/** Build an unsigned but well-formed JWT so `decode` can read its claims. */ +function tokenWithClaims(claims: Record): string { + const encode = (value: unknown) => + btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `${encode({ alg: "none", typ: "JWT" })}.${encode(claims)}.signature`; +} + +function captureInfo(run: () => void): string[] { + const lines: string[] = []; + const original = console.info; + console.info = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(" ")); + }; + try { + run(); + } finally { + console.info = original; + } + return lines; +} + +/** The audit line, ignoring the informational fallback notice. */ +function auditLine(lines: string[]): string | undefined { + return lines.find((line) => line.includes("AUDITLOG")); +} + +function withSubjectProp(value: string | undefined, run: () => T): T { + const previous = Deno.env.get(SUBJECT_PROP_ENV); + if (value === undefined) { + Deno.env.delete(SUBJECT_PROP_ENV); + } else { + Deno.env.set(SUBJECT_PROP_ENV, value); + } + try { + return run(); + } finally { + if (previous === undefined) { + Deno.env.delete(SUBJECT_PROP_ENV); + } else { + Deno.env.set(SUBJECT_PROP_ENV, previous); + } + } +} + +function logResponseFor(payload: Record | undefined, response = "ACCEPTED") { + return auditLine(captureInfo(() => serviceWithClaims(payload).logDisclaimerResponse(response))); +} + +Deno.test("logs the usage agreement in the Data2Evidence AUDITLOG format", () => { + const line = withSubjectProp("sub", () => logResponseFor({ sub: "user-123" })); + + assertMatch( + line ?? "", + /^\[Data2Evidence\]\[AUDITLOG\]\[\d+\] Usage agreement ACCEPTED by user: user-123$/, + ); +}); + +Deno.test("logs the response verbatim so a decline is distinguishable", () => { + const line = withSubjectProp("sub", () => logResponseFor({ sub: "user-123" }, "DECLINED")); + + assertMatch(line ?? "", /Usage agreement DECLINED by user: user-123$/); +}); + +Deno.test("prefers the oid from the nested third-party token", () => { + const line = withSubjectProp("sub", () => + logResponseFor({ + sub: "logto-subject", + oid: "top-level-oid", + thirdPartyToken: tokenWithClaims({ oid: "azure-ad-oid" }), + }), + ); + + assertMatch(line ?? "", /by user: azure-ad-oid$/); +}); + +Deno.test("falls back to the configured subject prop when there is no third-party token", () => { + const line = withSubjectProp("custom_id", () => + logResponseFor({ custom_id: "claim-identity", oid: "top-level-oid", sub: "logto-subject" }), + ); + + assertMatch(line ?? "", /by user: claim-identity$/); +}); + +Deno.test("falls back to the oid claim when the configured subject prop is absent", () => { + const line = withSubjectProp("custom_id", () => + logResponseFor({ oid: "top-level-oid", sub: "logto-subject" }), + ); + + assertMatch(line ?? "", /by user: top-level-oid$/); +}); + +Deno.test("falls back to the Logto subject when no other identity claim is present", () => { + const line = withSubjectProp("custom_id", () => logResponseFor({ sub: "logto-subject" })); + + assertMatch(line ?? "", /by user: logto-subject$/); +}); + +Deno.test("falls back to the Logto identity when the third-party token is malformed", () => { + const line = withSubjectProp("sub", () => + logResponseFor({ sub: "logto-subject", thirdPartyToken: "not-a-jwt" }), + ); + + assertMatch(line ?? "", /by user: logto-subject$/); +}); + +Deno.test("falls back to the Logto identity when the third-party token carries no oid", () => { + const line = withSubjectProp("sub", () => + logResponseFor({ sub: "logto-subject", thirdPartyToken: tokenWithClaims({ upn: "no-oid-here" }) }), + ); + + assertMatch(line ?? "", /by user: logto-subject$/); +}); + +Deno.test("defaults the subject prop to sub when the env var is unset", () => { + const line = withSubjectProp(undefined, () => logResponseFor({ sub: "logto-subject" })); + + assertMatch(line ?? "", /by user: logto-subject$/); +}); + +Deno.test("announces that it fell back to the Logto identity", () => { + const lines = withSubjectProp("sub", () => + captureInfo(() => serviceWithClaims({ sub: "user-123" }).logDisclaimerResponse("ACCEPTED")), + ); + + assertEquals( + lines.some((line) => line.includes("third-party token not found or invalid")), + true, + ); +}); + +Deno.test("rejects a missing response with a 400 instead of logging", () => { + const error = assertThrows( + () => serviceWithClaims({ sub: "user-123" }).logDisclaimerResponse(""), + HttpException, + ) as HttpException; + + assertEquals(error.status, 400); + assertMatch(error.message, /Log response is missing in the request body/); +}); + +Deno.test("does not log an audit line when the response is missing", () => { + const lines = captureInfo(() => { + try { + serviceWithClaims({ sub: "user-123" }).logDisclaimerResponse(""); + } catch { + // asserted in the test above; here we only care that nothing was logged + } + }); + + assertEquals(auditLine(lines), undefined); +}); + +Deno.test("still logs when the request carries no auth claims at all", () => { + const line = withSubjectProp("sub", () => logResponseFor(undefined)); + + assertMatch(line ?? "", /Usage agreement ACCEPTED by user: undefined$/); +}); + diff --git a/plugins/functions/portal/src/audit/audit.service.ts b/plugins/functions/portal/src/audit/audit.service.ts new file mode 100644 index 0000000000..8e916dd390 --- /dev/null +++ b/plugins/functions/portal/src/audit/audit.service.ts @@ -0,0 +1,51 @@ +import { HttpException, Injectable, SCOPE } from "@danet/core"; +import { JwtPayload, decode } from "jsonwebtoken"; +import { RequestContextService } from "../common/request-context.service.ts"; + +@Injectable({ scope: SCOPE.REQUEST }) +export class AuditService { + constructor(private readonly requestContextService: RequestContextService) {} + + logDisclaimerResponse(response: string) { + if (!response) { + throw new HttpException(400, "Log response is missing in the request body"); + } + + const idpUserId = this.resolveIdpUserId(); + + try { + console.info( + `[Data2Evidence][AUDITLOG][${Date.now()}] Usage agreement ${response} by user: ${idpUserId}`, + ); + } catch (error) { + console.error(`[d2e-compat] /trex/log error: ${error}`); + throw new HttpException(500, "Log write failed"); + } + } + + // The request context middleware decoded the JWT and stashed the full claims. + private resolveIdpUserId(): string | undefined { + const payload = (this.requestContextService.getAuthToken() ?? {}) as JwtPayload & + Record; + const subjectProp = Deno.env.get("GATEWAY__IDP_SUBJECT_PROP") ?? "sub"; + + try { + // Preferred: decode the nested Azure AD token and use its oid. + const thirdPartyToken = payload["thirdPartyToken"] as string | undefined; + if (!thirdPartyToken) throw new Error("no thirdPartyToken"); + const oid = (decode(thirdPartyToken) as JwtPayload | null)?.["oid"] as string | undefined; + if (!oid) throw new Error("no oid in thirdPartyToken"); + return oid; + } catch { + // Fallback: GATEWAY__IDP_SUBJECT_PROP claim, then "oid", then the Logto subject. + console.info( + "[d2e-compat] /trex/log: third-party token not found or invalid, using Logto identity", + ); + return ( + (payload[subjectProp] as string | undefined) ?? + (payload["oid"] as string | undefined) ?? + payload.sub + ); + } + } +} diff --git a/plugins/ui/apps/portal/src/axios/system-portal.test.ts b/plugins/ui/apps/portal/src/axios/system-portal.test.ts new file mode 100644 index 0000000000..dc48bcd3b3 --- /dev/null +++ b/plugins/ui/apps/portal/src/axios/system-portal.test.ts @@ -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; + +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 }, + }); + }); +}); diff --git a/plugins/ui/apps/portal/src/axios/system-portal.ts b/plugins/ui/apps/portal/src/axios/system-portal.ts index 243bf69451..0ad5b055a1 100644 --- a/plugins/ui/apps/portal/src/axios/system-portal.ts +++ b/plugins/ui/apps/portal/src/axios/system-portal.ts @@ -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({ baseURL: SYSTEM_PORTAL_URL, diff --git a/plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx b/plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx index 2e92e9ec4c..02eaa360d3 100644 --- a/plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx +++ b/plugins/ui/apps/portal/src/containers/shared/Legal/DisclaimerDialog.tsx @@ -15,9 +15,12 @@ import "./DisclaimerDialog.scss"; const logUserResponse = async (logResponse: LogResponseType): Promise => { 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 = () => { @@ -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]);