Skip to content

Commit 6e33404

Browse files
committed
JIT WIP, username in metrics
1 parent 6d04eed commit 6e33404

File tree

14 files changed

+447
-41
lines changed

14 files changed

+447
-41
lines changed

src/lib/components/MissingRoleAlert.svelte

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
errorCode?: string;
1111
message?: string;
1212
bankId?: string;
13+
/** When true, JIT will auto-grant this role on first use — show as informational, not blocking */
14+
jitCovered?: boolean;
1315
}
1416
15-
let { roles, errorCode, message, bankId }: Props = $props();
17+
let { roles, errorCode, message, bankId, jitCovered = false }: Props = $props();
1618
1719
let isExpanded = $state(false);
1820
let isSubmitting = $state(false);
@@ -110,15 +112,19 @@
110112
}
111113
</script>
112114

113-
<div class="alert alert-missing-role" class:expanded={isExpanded}>
115+
<div class="alert" class:alert-missing-role={!jitCovered} class:alert-jit-covered={jitCovered} class:expanded={isExpanded}>
114116
<button
115117
type="button"
116118
class="alert-header"
117119
onclick={() => isExpanded = !isExpanded}
118120
>
119-
<span class="alert-icon">🔒</span>
121+
<span class="alert-icon">{jitCovered ? "" : "🔒"}</span>
120122
<span class="alert-title">
121-
<strong>Missing Entitlement{roles.length > 1 ? "s" : ""}:</strong>
123+
{#if jitCovered}
124+
<strong>JIT Entitlement{roles.length > 1 ? "s" : ""}:</strong>
125+
{:else}
126+
<strong>Missing Entitlement{roles.length > 1 ? "s" : ""}:</strong>
127+
{/if}
122128
<span class="role-preview">{roles.join(", ")}</span>
123129
</span>
124130
{#if errorCode}
@@ -163,7 +169,11 @@
163169
<MessageBox message={submitError} type="error" />
164170
{/if}
165171

166-
{#if submitSuccess}
172+
{#if jitCovered}
173+
<div class="jit-info">
174+
This entitlement will be <strong>automatically granted</strong> when you use this feature. No action needed.
175+
</div>
176+
{:else if submitSuccess}
167177
<div class="submit-success">
168178
Thanks, an Entitlement Request has been generated. Please ask your administrator to accept it using the <a href="/rbac/entitlement-requests" class="entitlement-requests-link">Entitlement Requests page</a>.
169179
</div>
@@ -185,10 +195,12 @@
185195
</div>
186196
{/if}
187197

188-
<div class="tip-box">
189-
<strong>💡 Tip:</strong> If you have recently been granted this entitlement,
190-
you should <strong>log out and log back in</strong> again.
191-
</div>
198+
{#if !jitCovered}
199+
<div class="tip-box">
200+
<strong>💡 Tip:</strong> If you have recently been granted this entitlement,
201+
you should <strong>log out and log back in</strong> again.
202+
</div>
203+
{/if}
192204
</div>
193205
{/if}
194206
</div>
@@ -216,6 +228,23 @@
216228
color: rgb(var(--color-warning-200));
217229
}
218230
231+
.alert-jit-covered {
232+
background: #f0fdf4;
233+
border: 2px solid #86efac;
234+
color: #166534;
235+
padding: 0.75rem 1rem;
236+
}
237+
238+
.alert-jit-covered.expanded {
239+
padding: 1rem 1.25rem;
240+
}
241+
242+
:global([data-mode="dark"]) .alert-jit-covered {
243+
background: rgba(34, 197, 94, 0.1);
244+
border-color: rgba(34, 197, 94, 0.4);
245+
color: #4ade80;
246+
}
247+
219248
.alert-header {
220249
display: flex;
221250
align-items: center;
@@ -316,6 +345,14 @@
316345
color: rgb(var(--color-warning-100));
317346
}
318347
348+
.alert-jit-covered .entitlement-name {
349+
color: #166534;
350+
}
351+
352+
:global([data-mode="dark"]) .alert-jit-covered .entitlement-name {
353+
color: #4ade80;
354+
}
355+
319356
.bank-code {
320357
background: rgba(0, 0, 0, 0.1);
321358
padding: 0.125rem 0.5rem;
@@ -394,6 +431,23 @@
394431
}
395432
}
396433
434+
.jit-info {
435+
margin-top: 1rem;
436+
padding: 0.75rem;
437+
background: rgba(34, 197, 94, 0.1);
438+
border: 1px solid rgba(34, 197, 94, 0.3);
439+
border-radius: 4px;
440+
color: #166534;
441+
font-size: 0.875rem;
442+
font-weight: 500;
443+
}
444+
445+
:global([data-mode="dark"]) .jit-info {
446+
background: rgba(34, 197, 94, 0.15);
447+
border-color: rgba(34, 197, 94, 0.4);
448+
color: #4ade80;
449+
}
450+
397451
.submit-success {
398452
margin-top: 1rem;
399453
padding: 0.75rem;

src/lib/components/PageRoleCheck.svelte

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@
1010
optional?: RoleRequirement[];
1111
requirementType?: "OR" | "AND";
1212
currentBankId?: string;
13+
jitEnabled?: boolean;
1314
children?: Snippet;
1415
}
1516
16-
let { userEntitlements, required, optional, requirementType = "OR", currentBankId, children }: Props =
17+
let { userEntitlements, required, optional, requirementType = "OR", currentBankId, jitEnabled = false, children }: Props =
1718
$props();
1819
1920
// Check required roles using the configured logic (AND or OR)
2021
let requiredCheck = $derived.by(() => {
21-
return checkRoles(userEntitlements || [], required || [], currentBankId, requirementType);
22+
return checkRoles(userEntitlements || [], required || [], currentBankId, requirementType, jitEnabled);
2223
});
2324
2425
let showContent = $derived(requiredCheck.hasAllRoles);
@@ -51,7 +52,7 @@
5152
// Check optional roles (informational — content still renders)
5253
let optionalCheck = $derived.by(() => {
5354
if (!optional || optional.length === 0) return null;
54-
return checkRoles(userEntitlements || [], optional, currentBankId);
55+
return checkRoles(userEntitlements || [], optional, currentBankId, "OR", jitEnabled);
5556
});
5657
5758
let missingOptionalRoles = $derived(
@@ -84,6 +85,14 @@
8485
{/if}
8586

8687
{#if showContent && children}
88+
{#if requiredCheck.jitRoles.length > 0}
89+
<div class="jit-note" data-testid="jit-entitlements-note">
90+
<span class="note-icon">&#x26A1;</span>
91+
<span>
92+
Just In Time Entitlements active: {requiredCheck.jitRoles.map((r) => r.role).join(", ")} will be auto-granted on first use.
93+
</span>
94+
</div>
95+
{/if}
8796
{#if scopeMessage}
8897
<div class="scope-note">
8998
<span class="note-icon">&#x2139;&#xFE0F;</span>
@@ -177,6 +186,26 @@
177186
color: #fbbf24;
178187
}
179188
189+
.jit-note {
190+
display: flex;
191+
align-items: flex-start;
192+
gap: 0.5rem;
193+
padding: 0.75rem 1rem;
194+
margin-bottom: 1rem;
195+
background: #f0fdf4;
196+
border: 1px solid #86efac;
197+
border-radius: 0.5rem;
198+
color: #166534;
199+
font-size: 0.875rem;
200+
line-height: 1.5;
201+
}
202+
203+
:global([data-mode="dark"]) .jit-note {
204+
background: rgba(34, 197, 94, 0.1);
205+
border-color: rgba(34, 197, 94, 0.3);
206+
color: #4ade80;
207+
}
208+
180209
.optional-role-note {
181210
display: flex;
182211
align-items: flex-start;

src/lib/components/metrics/MetricsQueryForm.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
direction: string;
1212
consumer_id: string;
1313
user_id: string;
14-
user_name: string;
14+
username: string;
1515
anon: string;
1616
url: string;
1717
app_name: string;
@@ -268,11 +268,11 @@
268268
/>
269269
</div>
270270
<div class="form-field">
271-
<label for="user_name">User ID</label>
271+
<label for="username">User ID</label>
272272
<input
273273
type="text"
274-
id="user_name"
275-
bind:value={queryForm.user_name}
274+
id="username"
275+
bind:value={queryForm.username}
276276
placeholder="Filter by user ID"
277277
onblur={handleFieldChange}
278278
class="form-input"

src/lib/config/navigation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
Radio,
3737
FileSignature,
3838
Search,
39+
ToggleLeft,
3940
} from "@lucide/svelte";
4041
import { env } from "$env/dynamic/public";
4142

@@ -121,6 +122,11 @@ function buildSystemItems(): NavigationItem[] {
121122
label: "Database Pool",
122123
iconComponent: Waves,
123124
},
125+
{
126+
href: "/system/features",
127+
label: "Features",
128+
iconComponent: ToggleLeft,
129+
},
124130
{
125131
href: "/system/featured-collections",
126132
label: "Featured Collections",

src/lib/utils/jwt.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ export interface JWTPayload {
77
[key: string]: any;
88
sub?: string;
99
username?: string;
10-
user_name?: string;
1110
login?: string;
1211
user_id?: string;
1312
preferred_username?: string;
@@ -36,7 +35,6 @@ export function extractUsernameFromJWT(jwt: string): string {
3635
if (decoded.name) userInfo.push(`name:${decoded.name}`);
3736
if (decoded.preferred_username) userInfo.push(`preferred_username:${decoded.preferred_username}`);
3837
if (decoded.username) userInfo.push(`username:${decoded.username}`);
39-
if (decoded.user_name) userInfo.push(`user_name:${decoded.user_name}`);
4038
if (decoded.login) userInfo.push(`login:${decoded.login}`);
4139

4240
// System identifiers

src/lib/utils/roleChecker.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface RoleCheckResult {
3030
hasAllRoles: boolean;
3131
missingRoles: RoleRequirement[];
3232
hasRoles: RoleRequirement[];
33+
/** Roles the user doesn't explicitly have, but JIT will auto-grant on use */
34+
jitRoles: RoleRequirement[];
3335
}
3436

3537
/**
@@ -302,23 +304,67 @@ export function getPageRoles(routeId: string): PageRoleConfig | undefined {
302304
return SITE_MAP[key];
303305
}
304306

307+
/**
308+
* Roles excluded from JIT auto-granting (to prevent privilege escalation).
309+
*/
310+
const JIT_EXCLUDED_ROLES = new Set([
311+
"CanCreateEntitlementAtOneBank",
312+
"CanCreateEntitlementAtAnyBank",
313+
]);
314+
315+
/**
316+
* Check whether JIT can cover a missing role for this user.
317+
* JIT requires:
318+
* - JIT feature enabled on the OBP instance
319+
* - User holds CanCreateEntitlementAtAnyBank (system-wide), OR
320+
* CanCreateEntitlementAtOneBank for the relevant bank (bank-scoped roles)
321+
* - The missing role is not one of the excluded meta-roles
322+
*/
323+
function canJitGrant(
324+
requirement: RoleRequirement,
325+
userEntitlements: UserEntitlement[],
326+
currentBankId?: string,
327+
): boolean {
328+
if (JIT_EXCLUDED_ROLES.has(requirement.role)) return false;
329+
330+
// CanCreateEntitlementAtAnyBank covers everything
331+
const hasAnyBank = userEntitlements.some(
332+
(e) => e.role_name === "CanCreateEntitlementAtAnyBank",
333+
);
334+
if (hasAnyBank) return true;
335+
336+
// For bank-scoped roles, CanCreateEntitlementAtOneBank at the relevant bank also works
337+
if (requirement.bankScoped || requirement.bankId) {
338+
const bankId = requirement.bankId || currentBankId;
339+
if (!bankId) return false;
340+
return userEntitlements.some(
341+
(e) => e.role_name === "CanCreateEntitlementAtOneBank" && e.bank_id === bankId,
342+
);
343+
}
344+
345+
return false;
346+
}
347+
305348
/**
306349
* Check if a user has the required roles.
307350
*
308351
* @param userEntitlements - List of entitlements the user has
309352
* @param requiredRoles - List of roles to check
310353
* @param currentBankId - The currently selected bank ID (for bankScoped role checks)
311354
* @param requirementType - "OR" (default): user needs at least one; "AND": user needs all
355+
* @param jitEnabled - Whether Just In Time entitlements are enabled on this OBP instance
312356
* @returns RoleCheckResult with missing and present roles
313357
*/
314358
export function checkRoles(
315359
userEntitlements: UserEntitlement[],
316360
requiredRoles: RoleRequirement[],
317361
currentBankId?: string,
318362
requirementType: "OR" | "AND" = "OR",
363+
jitEnabled: boolean = false,
319364
): RoleCheckResult {
320365
const missingRoles: RoleRequirement[] = [];
321366
const hasRoles: RoleRequirement[] = [];
367+
const jitRoles: RoleRequirement[] = [];
322368

323369
for (const requirement of requiredRoles) {
324370
const hasRole = userEntitlements.some((entitlement) => {
@@ -340,22 +386,27 @@ export function checkRoles(
340386

341387
if (hasRole) {
342388
hasRoles.push(requirement);
389+
} else if (jitEnabled && canJitGrant(requirement, userEntitlements, currentBankId)) {
390+
jitRoles.push(requirement);
343391
} else {
344392
missingRoles.push(requirement);
345393
}
346394
}
347395

348396
logger.debug(
349-
`Role check (${requirementType}): ${hasRoles.length}/${requiredRoles.length} roles present`,
397+
`Role check (${requirementType}): ${hasRoles.length} present, ${jitRoles.length} JIT-covered, ${missingRoles.length} missing / ${requiredRoles.length} total`,
350398
);
351399

400+
// JIT-covered roles count as "effectively has" for access decisions
401+
const effectiveHasCount = hasRoles.length + jitRoles.length;
402+
352403
let hasAccess: boolean;
353404
if (requirementType === "AND") {
354-
// AND: user needs ALL required roles
405+
// AND: user needs ALL required roles (explicitly or via JIT)
355406
hasAccess = requiredRoles.length === 0 || missingRoles.length === 0;
356407
} else {
357-
// OR: user needs at least one of the required roles
358-
hasAccess = requiredRoles.length === 0 || hasRoles.length > 0;
408+
// OR: user needs at least one of the required roles (explicitly or via JIT)
409+
hasAccess = requiredRoles.length === 0 || effectiveHasCount > 0;
359410
}
360411

361412
if (!hasAccess) {
@@ -366,6 +417,7 @@ export function checkRoles(
366417
hasAllRoles: hasAccess,
367418
missingRoles: hasAccess ? [] : missingRoles,
368419
hasRoles,
420+
jitRoles,
369421
};
370422
}
371423

src/routes/(protected)/+layout.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
optional={pageRoles.optional}
1818
requirementType={pageRoles.requirementType}
1919
currentBankId={currentBank.bankId}
20+
jitEnabled={data.jitEnabled}
2021
>
2122
{@render children()}
2223
</PageRoleCheck>

0 commit comments

Comments
 (0)