Skip to content

Commit de992dd

Browse files
committed
Code review improvements
1 parent 2726647 commit de992dd

4 files changed

Lines changed: 59 additions & 17 deletions

File tree

apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.session-duration.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
44
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5+
import {
6+
ALLOWED_SESSION_DURATION_VALUES,
7+
isAllowedSessionDuration,
8+
} from "~/services/sessionDuration.server";
59

610
const ParamsSchema = z.object({
711
organizationId: z.string(),
@@ -12,15 +16,33 @@ const RequestBodySchema = z.object({
1216
* Maximum session lifetime (seconds) for members of this organization, or
1317
* null to remove the cap. When set, this caps each member's
1418
* `User.sessionDuration` and is enforced on the user's next request.
19+
*
20+
* Must be one of the values in `SESSION_DURATION_OPTIONS` so the cap always
21+
* maps to a labeled dropdown option for users — otherwise users see fallback
22+
* labels like "7200 seconds" in the UI. To allow a new value, add it to
23+
* `SESSION_DURATION_OPTIONS`.
1524
*/
16-
maxSessionDuration: z.number().int().positive().nullable(),
25+
maxSessionDuration: z
26+
.number()
27+
.int()
28+
.positive()
29+
.nullable()
30+
.refine((v) => v === null || isAllowedSessionDuration(v), {
31+
message: `maxSessionDuration must be one of: ${[...ALLOWED_SESSION_DURATION_VALUES]
32+
.sort((a, b) => a - b)
33+
.join(", ")}`,
34+
}),
1735
});
1836

1937
export async function action({ request, params }: ActionFunctionArgs) {
2038
await requireAdminApiRequest(request);
2139

2240
const { organizationId } = ParamsSchema.parse(params);
23-
const body = RequestBodySchema.parse(await request.json());
41+
const parseResult = RequestBodySchema.safeParse(await request.json());
42+
if (!parseResult.success) {
43+
return json({ success: false, errors: parseResult.error.flatten() }, { status: 400 });
44+
}
45+
const body = parseResult.data;
2446

2547
const organization = await prisma.organization.update({
2648
where: { id: organizationId },

apps/webapp/app/services/session.server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { redirect } from "@remix-run/node";
22
import { $replica } from "~/db.server";
33
import { getUserById } from "~/models/user.server";
44
import { sanitizeRedirectPath } from "~/utils";
5+
import { extractClientIp } from "~/utils/extractClientIp.server";
56
import { authenticator } from "./auth.server";
67
import { getImpersonationId } from "./impersonation.server";
78
import { logger } from "./logger.server";
@@ -30,22 +31,26 @@ async function enforceSessionExpiry(
3031
// when one is configured (falls back to primary). Stale-by-replica-lag is
3132
// acceptable here because the worst case is a session living a few seconds
3233
// past its cap on the very first request after a cap change.
33-
const { durationSeconds, orgCapSeconds, userSettingSeconds } =
34+
const { durationSeconds, orgCapSeconds, cappingOrgId, userSettingSeconds } =
3435
await getEffectiveSessionDuration(userId, $replica);
3536
if (!isSessionExpired(session, durationSeconds)) return;
3637

3738
const issuedAt = getSessionIssuedAt(session);
3839
// HIPAA audit trail: structured log lands in CloudWatch via stdout. Use
3940
// the stable `event` field to filter/aggregate auto-logout events.
41+
// `sourceIp` uses ALB's appended (last) X-Forwarded-For element, not the
42+
// first one, since the leading element is client-supplied and spoofable.
4043
logger.info("Auto-logout: session exceeded effective duration", {
4144
event: "session.auto_logout",
4245
userId,
4346
impersonatedUserId,
47+
cappingOrgId,
4448
effectiveDurationSeconds: durationSeconds,
4549
userSettingSeconds,
4650
orgCapSeconds,
4751
sessionAgeMs: issuedAt === null ? null : Date.now() - issuedAt,
4852
requestPath: new URL(request.url).pathname,
53+
sourceIp: extractClientIp(request.headers.get("x-forwarded-for")),
4954
});
5055
throw redirect("/logout");
5156
}

apps/webapp/app/services/sessionDuration.server.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,31 +35,42 @@ export function isAllowedSessionDuration(value: number): boolean {
3535
return ALLOWED_SESSION_DURATION_VALUES.has(value);
3636
}
3737

38+
export type OrganizationSessionCap = {
39+
/** The org cap in seconds. */
40+
orgCapSeconds: number;
41+
/** The id of the org whose cap is currently the most restrictive. */
42+
cappingOrgId: string;
43+
};
44+
3845
/**
39-
* Returns the most restrictive max session duration (in seconds) across all of
40-
* the user's organizations, ignoring orgs where it's null. Returns null when
41-
* no org has set a cap.
46+
* Returns the most restrictive max session duration across the user's orgs
47+
* along with the id of the org that owns it, ignoring orgs where the cap is
48+
* null. Returns null when no org has set a cap.
4249
*/
4350
export async function getOrganizationSessionCap(
4451
userId: string,
4552
client: PrismaClientOrTransaction = prisma
46-
): Promise<number | null> {
47-
const result = await client.organization.aggregate({
53+
): Promise<OrganizationSessionCap | null> {
54+
const tightest = await client.organization.findFirst({
4855
where: {
4956
members: { some: { userId } },
5057
maxSessionDuration: { not: null },
5158
deletedAt: null,
5259
},
53-
_min: { maxSessionDuration: true },
60+
orderBy: { maxSessionDuration: "asc" },
61+
select: { id: true, maxSessionDuration: true },
5462
});
55-
return result._min.maxSessionDuration ?? null;
63+
if (!tightest || tightest.maxSessionDuration === null) return null;
64+
return { orgCapSeconds: tightest.maxSessionDuration, cappingOrgId: tightest.id };
5665
}
5766

5867
export type EffectiveSessionDuration = {
5968
/** Effective session duration in seconds = min(user.sessionDuration, orgCap?). */
6069
durationSeconds: number;
6170
/** The org cap in seconds, or null if no org caps the user. */
6271
orgCapSeconds: number | null;
72+
/** The id of the org whose cap is currently in effect, or null. */
73+
cappingOrgId: string | null;
6374
/** The raw user setting in seconds. */
6475
userSettingSeconds: number;
6576
};
@@ -83,11 +94,12 @@ export async function getEffectiveSessionDuration(
8394

8495
const userSettingSeconds = user?.sessionDuration ?? DEFAULT_SESSION_DURATION_SECONDS;
8596
const durationSeconds =
86-
orgCap === null ? userSettingSeconds : Math.min(userSettingSeconds, orgCap);
97+
orgCap === null ? userSettingSeconds : Math.min(userSettingSeconds, orgCap.orgCapSeconds);
8798

8899
return {
89100
durationSeconds,
90-
orgCapSeconds: orgCap,
101+
orgCapSeconds: orgCap?.orgCapSeconds ?? null,
102+
cappingOrgId: orgCap?.cappingOrgId ?? null,
91103
userSettingSeconds,
92104
};
93105
}

apps/webapp/test/sessionDuration.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,26 +145,26 @@ describe("getOrganizationSessionCap", () => {
145145
async ({ prisma }) => {
146146
const user = await createUser(prisma, "multi-org@test.com");
147147
await createOrgWithMember(prisma, "loose-org", user.id, oneDay);
148-
await createOrgWithMember(prisma, "tight-org", user.id, oneHour);
148+
const tight = await createOrgWithMember(prisma, "tight-org", user.id, oneHour);
149149
await createOrgWithMember(prisma, "uncapped-org", user.id, null);
150150

151151
const cap = await getOrganizationSessionCap(user.id, prisma);
152-
expect(cap).toBe(oneHour);
152+
expect(cap).toEqual({ orgCapSeconds: oneHour, cappingOrgId: tight.id });
153153
}
154154
);
155155

156156
containerTest("ignores soft-deleted organizations", async ({ prisma }) => {
157157
const user = await createUser(prisma, "deleted-org-user@test.com");
158158
const tight = await createOrgWithMember(prisma, "deleted-tight", user.id, oneHour);
159-
await createOrgWithMember(prisma, "active-loose", user.id, oneDay);
159+
const loose = await createOrgWithMember(prisma, "active-loose", user.id, oneDay);
160160

161161
await prisma.organization.update({
162162
where: { id: tight.id },
163163
data: { deletedAt: new Date() },
164164
});
165165

166166
const cap = await getOrganizationSessionCap(user.id, prisma);
167-
expect(cap).toBe(oneDay);
167+
expect(cap).toEqual({ orgCapSeconds: oneDay, cappingOrgId: loose.id });
168168
});
169169
});
170170

@@ -178,17 +178,19 @@ describe("getEffectiveSessionDuration", () => {
178178
const result = await getEffectiveSessionDuration(user.id, prisma);
179179
expect(result.userSettingSeconds).toBe(oneDay);
180180
expect(result.orgCapSeconds).toBeNull();
181+
expect(result.cappingOrgId).toBeNull();
181182
expect(result.durationSeconds).toBe(oneDay);
182183
}
183184
);
184185

185186
containerTest("caps the user setting at the most restrictive org cap", async ({ prisma }) => {
186187
const user = await createUser(prisma, "effective-capped@test.com", oneYear);
187-
await createOrgWithMember(prisma, "effective-capped-org", user.id, oneHour);
188+
const org = await createOrgWithMember(prisma, "effective-capped-org", user.id, oneHour);
188189

189190
const result = await getEffectiveSessionDuration(user.id, prisma);
190191
expect(result.userSettingSeconds).toBe(oneYear);
191192
expect(result.orgCapSeconds).toBe(oneHour);
193+
expect(result.cappingOrgId).toBe(org.id);
192194
expect(result.durationSeconds).toBe(oneHour);
193195
});
194196

@@ -209,6 +211,7 @@ describe("getEffectiveSessionDuration", () => {
209211
const result = await getEffectiveSessionDuration("nonexistent-user-id", prisma);
210212
expect(result.userSettingSeconds).toBe(DEFAULT_SESSION_DURATION_SECONDS);
211213
expect(result.orgCapSeconds).toBeNull();
214+
expect(result.cappingOrgId).toBeNull();
212215
expect(result.durationSeconds).toBe(DEFAULT_SESSION_DURATION_SECONDS);
213216
}
214217
);

0 commit comments

Comments
 (0)