Skip to content
Open
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
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 {}
174 changes: 174 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,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<string, unknown>) {
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, unknown>): 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<T>(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<string, unknown> | 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$/);
});

51 changes: 51 additions & 0 deletions plugins/functions/portal/src/audit/audit.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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
);
}
}
}
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
Loading