Skip to content

Commit cab6f71

Browse files
sanjaydocclaude
andcommitted
Add self-service access requests + approvals (lightweight IGA)
A user requests membership in a group; a reviewer approves or denies. Approval grants the group through the normal Okta -> AD path (so nesting + ACLs apply), and every step is audited -- access via a reviewable workflow, not a favour. - requests.py: AccessRequest + RequestQueue - engine: request_access / approve_request / deny_request (audited), state persisted - cli: request, requests, approve, deny - api: GET/POST /requests, POST /requests/approve, /requests/deny - GUI: Requests view (queue + approve/deny buttons + file-a-request form), demo + live backends; snapshot exports the group list + request queue - 6 request tests (73 total, ruff-clean); browser-verified end-to-end Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9BehfGmiCrz3J15W18rc9
1 parent 21eff87 commit cab6f71

11 files changed

Lines changed: 398 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ Delivery, Platform, Data-Science + Lab-Ops, Compliance, Facilities, Legal, IT):
127127
| Module | What it does | Who it serves |
128128
|---|---|---|
129129
| **Identity & access** | Onboard/offboard, SCIM sync, nested-group ACLs, audited allow/deny, access reviews | IT, everyone |
130+
| **Access requests & approvals** | Self-service request → reviewer approve/deny → auto-provisioned through the normal Okta→AD path, audited | IT, managers, everyone |
130131
| **Endpoints / devices** | Images + ships a managed laptop per role (encryption, MDM, MFA, Iru/Ansible); wipe & return at offboard | IT |
131132
| **Compliance-gated access** | Training records (IACUC, biosafety…) that gate sensitive lab data; access auto-revokes when training lapses | Compliance, In-Vivo, IT |
132133
| **SaaS & cost** | Who has a seat in what and **what it costs**; orphaned-seat detection; provisioned on onboard, reclaimed on offboard | IT, Finance |
@@ -265,6 +266,9 @@ labsuite train --user nrahman --training IACUC # complete a training (unloc
265266
labsuite offboard --user nrahman # same-day, verified clean
266267

267268
# governance + operations
269+
labsuite request --user lpark --group Data-Science --why "ML side-project"
270+
labsuite requests # the request queue
271+
labsuite approve --id REQ-0001 # grants it through Okta -> AD
268272
labsuite review # access review + anomaly flags
269273
labsuite compliance # training records
270274
labsuite devices # managed laptop fleet

‎RUN.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ labsuite train --user nrahman --training IACUC # complete a training
5151
labsuite train --user nrahman --training Biosafety --expire # lapse it (revokes gated access)
5252
labsuite compliance # all training records
5353

54+
# Access requests + approvals (self-service governance)
55+
labsuite request --user lpark --group Data-Science --why "ML side-project"
56+
labsuite requests # the request queue
57+
labsuite approve --id REQ-0001 # grant it (Okta -> AD)
58+
labsuite deny --id REQ-0002 --note "not justified"
59+
5460
# Governance + operations
5561
labsuite login --user anguyen # demo password is used by default
5662
labsuite sync # run the SCIM reconcile
@@ -99,6 +105,7 @@ labsuite serve # http://127.0.0.1:8000
99105
- `GET /devices`, `GET /compliance`, `POST /compliance/complete` · `/expire`
100106
- `GET /ops`, `GET /saas`, `GET /assets`, `GET /inventory`, `GET /vendors`, `GET /safety`
101107
- `POST /assets/maintenance`, `/inventory/reorder`, `/safety/resolve`, `/vendors/renew`, `/saas/grant`, `/saas/revoke`
108+
- `GET /requests`, `POST /requests` · `/requests/approve` · `/requests/deny`
102109

103110
The web GUI is **fully operable** — onboard/offboard, complete/lapse training,
104111
grant/revoke SaaS seats, mark maintenance done, reorder stock, renew vendors, and

‎docs/app/app.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ class DemoEngine {
5353
this.inventory = ops.inventory || [];
5454
this.vendors = ops.vendors || [];
5555
this.safety = ops.safety || [];
56+
this.requests = (data.requests || []).map((r) => ({ ...r }));
57+
this.reqCounter = this._maxReq();
5658
}
5759

5860
// ---- compliance ---------------------------------------------------
@@ -127,6 +129,35 @@ class DemoEngine {
127129
if (v) { v.renewal_in_days = 365; this.log("operations", "vendor.renew", name, "success"); }
128130
return v;
129131
}
132+
// ---- access requests + approvals ---------------------------------
133+
requestAccess(user, group, justification) {
134+
this.reqCounter = (this.reqCounter || this._maxReq()) + 1;
135+
const req = { id: "REQ-" + String(this.reqCounter).padStart(4, "0"), requester: user, group,
136+
justification: justification || "", status: "pending", decided_by: "", note: "" };
137+
this.requests.push(req);
138+
this.log(user, "access.request", `${req.id}:${group}`, "success", justification || "");
139+
return req;
140+
}
141+
_maxReq() {
142+
return this.requests.reduce((m, r) => Math.max(m, parseInt(r.id.replace(/\D/g, ""), 10) || 0), 0);
143+
}
144+
approveRequest(id) {
145+
const r = this.requests.find((x) => x.id === id);
146+
if (!r || r.status !== "pending") return r;
147+
const u = this.users[r.requester];
148+
if (u && !u.okta_groups.includes(r.group)) u.okta_groups.push(r.group);
149+
r.status = "approved"; r.decided_by = "it-admin";
150+
this.log("it-admin", "request.approve", `${r.id}:${r.group}`, "success", `granted ${r.group} to ${r.requester}`);
151+
return r;
152+
}
153+
denyRequest(id, note) {
154+
const r = this.requests.find((x) => x.id === id);
155+
if (!r || r.status !== "pending") return r;
156+
r.status = "denied"; r.decided_by = "it-admin"; r.note = note || "";
157+
this.log("it-admin", "request.deny", `${r.id}:${r.group}`, "denied", note || "");
158+
return r;
159+
}
160+
130161
grantSaasSeat(username, app) {
131162
(this.saas[app] ||= { name: app, cost: (this.d.saas_catalog || {})[app] || 0, assignees: new Set() }).assignees.add(username);
132163
this.log("saas", "saas.grant", username, "success", app);
@@ -370,6 +401,10 @@ const demoBackend = {
370401
async renewVendor(name) { engine.renewVendor(name); },
371402
async grantSaasSeat(u, app) { engine.grantSaasSeat(u, app); },
372403
async revokeSaasSeat(u, app) { engine.revokeSaasSeat(u, app); },
404+
async requestsList() { return engine.requests.slice(); },
405+
async createRequest(u, g, j) { engine.requestAccess(u, g, j); },
406+
async approveRequest(id) { engine.approveRequest(id); },
407+
async denyRequest(id, note) { engine.denyRequest(id, note); },
373408
async usernames() { return Object.keys(engine.users).sort(); },
374409
};
375410

@@ -426,6 +461,10 @@ const liveBackend = {
426461
async renewVendor(name) { await this._post("/vendors/renew", { name }); },
427462
async grantSaasSeat(u, app) { await this._post("/saas/grant", { username: u, app_name: app }); },
428463
async revokeSaasSeat(u, app) { await this._post("/saas/revoke", { username: u, app_name: app }); },
464+
async requestsList() { try { return (await this._json("/requests")).requests; } catch { return []; } },
465+
async createRequest(u, g, j) { await this._post("/requests", { requester: u, group: g, justification: j }); },
466+
async approveRequest(id) { await this._post("/requests/approve", { request_id: id }); },
467+
async denyRequest(id, note) { await this._post("/requests/deny", { request_id: id, note }); },
429468
async usernames() {
430469
try { return (await this._json("/scim/v2/Users")).Resources.map((u) => u.userName).sort(); }
431470
catch { return Object.keys(engine.users).sort(); }
@@ -666,6 +705,34 @@ async function renderAudit() {
666705
</div>`).join("") : '<div class="muted">No events yet — try onboarding or a decision, then return here.</div>';
667706
}
668707

708+
async function renderRequests() {
709+
const [reqs, users] = await Promise.all([backend.requestsList(), backend.usernames()]);
710+
const groups = (D.all_groups || []).filter((g) => g !== "Everyone");
711+
const statusChip = (s) => s === "approved" ? '<span class="chip allow">approved</span>'
712+
: s === "denied" ? '<span class="chip deny">denied</span>' : '<span class="chip">pending</span>';
713+
const rows = reqs.slice().sort((a, b) => b.id.localeCompare(a.id)).map((r) => {
714+
const actions = r.status === "pending"
715+
? `${_actBtn("approve", `data-id="${esc(r.id)}"`, "approve")} ${_actBtn("deny", `data-id="${esc(r.id)}"`, "deny")}`
716+
: `<span class="muted">${esc(r.decided_by || "")}</span>`;
717+
return `<tr><td>${esc(r.id)}</td><td>${esc(r.requester)}</td><td><span class="tag">${esc(r.group)}</span></td>
718+
<td class="muted">${esc(r.justification || "")}</td><td>${statusChip(r.status)}</td><td>${actions}</td></tr>`;
719+
}).join("") || `<tr><td colspan="6" class="muted">No requests yet — file one below.</td></tr>`;
720+
byId("req-table").innerHTML =
721+
`<tr><th>ID</th><th>Requester</th><th>Group</th><th>Justification</th><th>Status</th><th></th></tr>${rows}`;
722+
byId("req-user").innerHTML = users.map((u) => `<option>${esc(u)}</option>`).join("");
723+
byId("req-group").innerHTML = groups.map((g) => `<option>${esc(g)}</option>`).join("");
724+
$$("#req-table button.act").forEach((b) => b.onclick = async () => {
725+
if (b.dataset.act === "approve") await backend.approveRequest(b.dataset.id);
726+
else await backend.denyRequest(b.dataset.id, "");
727+
renderRequests();
728+
});
729+
byId("req-submit").onclick = async () => {
730+
await backend.createRequest(byId("req-user").value, byId("req-group").value, byId("req-why").value);
731+
byId("req-why").value = "";
732+
renderRequests();
733+
};
734+
}
735+
669736
async function renderCompliance() {
670737
const records = await backend.complianceRecords();
671738
const trainings = ["Data-Handling", "Biosafety", "Chemical-Safety", "IACUC"];
@@ -810,6 +877,7 @@ const RENDERERS = {
810877
overview: renderOverview, directory: renderDirectory, explorer: renderExplorer,
811878
review: renderReview, audit: renderAudit, architecture: renderArchitecture,
812879
devices: renderDevices, compliance: renderCompliance, saas: renderSaas, ops: renderOps,
880+
requests: renderRequests,
813881
};
814882

815883
function go(view) {

‎docs/app/data.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,29 @@ window.LABSUITE_DATA = {
610610
}
611611
]
612612
},
613+
"all_groups": [
614+
"Bio",
615+
"CI-Operators",
616+
"Compliance",
617+
"Compliance-Read",
618+
"Data-Science",
619+
"Delivery",
620+
"Domain-Admins",
621+
"Everyone",
622+
"Facilities",
623+
"GPU-Cluster-Users",
624+
"Histology",
625+
"IT",
626+
"In-Vivo",
627+
"Lab",
628+
"Lab-DAQ-Operators",
629+
"Lab-Ops",
630+
"Legal",
631+
"Legal-Privileged",
632+
"Platform",
633+
"Research"
634+
],
635+
"requests": [],
613636
"users": [
614637
{
615638
"username": "anguyen",

‎docs/app/index.html‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
<button class="navitem" data-view="devices">Devices</button>
4242
<div class="grp">Govern</div>
4343
<button class="navitem" data-view="explorer">Access explorer</button>
44+
<button class="navitem" data-view="requests">Requests</button>
4445
<button class="navitem" data-view="review">Access review</button>
4546
<button class="navitem" data-view="compliance">Compliance</button>
4647
<button class="navitem" data-view="audit">Audit log</button>
@@ -152,6 +153,22 @@ <h2>Audit log</h2>
152153
<div class="card" id="audit-list"></div>
153154
</section>
154155

156+
<!-- Requests -->
157+
<section class="view" id="view-requests">
158+
<h2>Access requests</h2>
159+
<div class="sub">Self-service requests → reviewer approves or denies. Approval grants the group through the normal Okta → AD path — all audited.</div>
160+
<div class="card" style="padding:6px 18px;margin-bottom:16px"><table id="req-table"></table></div>
161+
<div class="card">
162+
<div class="label" style="margin-bottom:8px">File a request</div>
163+
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
164+
<div class="field" style="margin:0"><label>User</label><select id="req-user" style="width:auto"></select></div>
165+
<div class="field" style="margin:0"><label>Group</label><select id="req-group" style="width:auto"></select></div>
166+
<div class="field" style="margin:0;flex:1;min-width:180px"><label>Justification</label><input id="req-why" placeholder="why do you need this?" /></div>
167+
<button class="btn btn-sm" id="req-submit">Submit request</button>
168+
</div>
169+
</div>
170+
</section>
171+
155172
<!-- Compliance -->
156173
<section class="view" id="view-compliance">
157174
<h2>Compliance</h2>

‎scripts/export_snapshot.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ def main() -> None:
9494
"saas_role_apps": dict(SAAS_ROLE_APPS),
9595
"compliance_records": cp.compliance.all_records(),
9696
"operations": cp.ops.to_dict(),
97+
"all_groups": sorted({g.name for g in cp.ad.groups.values()} | set(cp.okta.groups)),
98+
"requests": cp.requests.to_dict()["requests"],
9799
"users": users,
98100
"ad_nesting": ad_nesting,
99101
"shares": shares,

‎src/labsuite/api.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,34 @@ def offboard(username: str = Body(..., embed=True)) -> dict:
119119
def sync() -> dict:
120120
return control.sync().to_dict()
121121

122+
# --------------------------------------------------------------- #
123+
# Access requests + approvals
124+
# --------------------------------------------------------------- #
125+
@app.get("/requests")
126+
def list_requests() -> dict:
127+
return {"requests": [r.to_dict() for r in control.requests.all()]}
128+
129+
@app.post("/requests")
130+
def create_request(requester: str = Body(...), group: str = Body(...), justification: str = Body(default="")) -> dict:
131+
try:
132+
return control.request_access(requester, group, justification).to_dict()
133+
except KeyError as exc:
134+
raise HTTPException(status_code=404, detail=str(exc)) from exc
135+
136+
@app.post("/requests/approve")
137+
def approve(request_id: str = Body(..., embed=True)) -> dict:
138+
try:
139+
return control.approve_request(request_id).to_dict()
140+
except KeyError as exc:
141+
raise HTTPException(status_code=404, detail=str(exc)) from exc
142+
143+
@app.post("/requests/deny")
144+
def deny(request_id: str = Body(...), note: str = Body(default="")) -> dict:
145+
try:
146+
return control.deny_request(request_id, note=note).to_dict()
147+
except KeyError as exc:
148+
raise HTTPException(status_code=404, detail=str(exc)) from exc
149+
122150
@app.get("/access/{username}")
123151
def access(username: str) -> dict:
124152
return control.resolve_access(username).to_dict()

‎src/labsuite/cli.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,51 @@ def cmd_compliance(args: argparse.Namespace) -> int:
239239
return 0
240240

241241

242+
def cmd_request(args: argparse.Namespace) -> int:
243+
cp = _load_or_build(args.state)
244+
try:
245+
req = cp.request_access(args.user, args.group, args.why or "")
246+
except KeyError as exc:
247+
raise SystemExit(str(exc)) from exc
248+
print(f"{_c(req.id, CYAN)} — {args.user} requests {args.group} {DIM}({req.status}){RESET}")
249+
_save(cp, args.state)
250+
return 0
251+
252+
253+
def cmd_requests(args: argparse.Namespace) -> int:
254+
cp = _load_or_build(args.state)
255+
_header("Access requests")
256+
for r in cp.requests.all():
257+
colour = GREEN if r.status == "approved" else RED if r.status == "denied" else CYAN
258+
by = f" by {r.decided_by}" if r.decided_by else ""
259+
print(f" {r.id} {r.requester:10} -> {r.group:18} {_c(r.status, colour)}{by} {DIM}{r.justification}{RESET}")
260+
if not cp.requests.requests:
261+
print(f" {DIM}(no requests){RESET}")
262+
return 0
263+
264+
265+
def cmd_approve(args: argparse.Namespace) -> int:
266+
cp = _load_or_build(args.state)
267+
try:
268+
req = cp.approve_request(args.id)
269+
except KeyError as exc:
270+
raise SystemExit(str(exc)) from exc
271+
print(f"{req.id}: {_c('approved', GREEN)} — {req.requester} granted {req.group}")
272+
_save(cp, args.state)
273+
return 0
274+
275+
276+
def cmd_deny(args: argparse.Namespace) -> int:
277+
cp = _load_or_build(args.state)
278+
try:
279+
req = cp.deny_request(args.id, note=args.note or "")
280+
except KeyError as exc:
281+
raise SystemExit(str(exc)) from exc
282+
print(f"{req.id}: {_c('denied', RED)}")
283+
_save(cp, args.state)
284+
return 0
285+
286+
242287
def cmd_devices(args: argparse.Namespace) -> int:
243288
cp = _load_or_build(args.state)
244289
_header("Managed device fleet")
@@ -473,6 +518,24 @@ def build_parser() -> argparse.ArgumentParser:
473518
p.set_defaults(func=cmd_check)
474519

475520
sub.add_parser("review", help="quarterly access review with anomaly flags").set_defaults(func=cmd_review)
521+
522+
p = sub.add_parser("request", help="request access to a group")
523+
p.add_argument("--user", required=True)
524+
p.add_argument("--group", required=True)
525+
p.add_argument("--why", help="justification")
526+
p.set_defaults(func=cmd_request)
527+
528+
sub.add_parser("requests", help="list access requests").set_defaults(func=cmd_requests)
529+
530+
p = sub.add_parser("approve", help="approve an access request")
531+
p.add_argument("--id", required=True)
532+
p.set_defaults(func=cmd_approve)
533+
534+
p = sub.add_parser("deny", help="deny an access request")
535+
p.add_argument("--id", required=True)
536+
p.add_argument("--note")
537+
p.set_defaults(func=cmd_deny)
538+
476539
sub.add_parser("devices", help="list the managed laptop fleet").set_defaults(func=cmd_devices)
477540
sub.add_parser("compliance", help="show training records").set_defaults(func=cmd_compliance)
478541

0 commit comments

Comments
 (0)