primarypath-server 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. app/__init__.py +351 -0
  2. app/_version.py +8 -0
  3. app/agents.py +150 -0
  4. app/alembic.ini +37 -0
  5. app/auth.py +714 -0
  6. app/chat.py +122 -0
  7. app/config.py +258 -0
  8. app/costgov.py +345 -0
  9. app/db.py +56 -0
  10. app/faults.py +26 -0
  11. app/grants.py +122 -0
  12. app/idempotency.py +58 -0
  13. app/jcs.py +117 -0
  14. app/ledger/__init__.py +41 -0
  15. app/ledger/fold.py +74 -0
  16. app/ledger/hashing.py +19 -0
  17. app/ledger/projection.py +389 -0
  18. app/ledger/store.py +1042 -0
  19. app/ledger/verify.py +31 -0
  20. app/main.py +50 -0
  21. app/maintenance.py +56 -0
  22. app/metrics.py +89 -0
  23. app/migrations/env.py +20 -0
  24. app/migrations/script.py.mako +23 -0
  25. app/migrations/versions/0001_baseline.py +19 -0
  26. app/migrations/versions/0002_events_and_projection.py +78 -0
  27. app/migrations/versions/0003_slack_messages.py +27 -0
  28. app/migrations/versions/0004_sessions.py +31 -0
  29. app/migrations/versions/0005_siem_cursor.py +26 -0
  30. app/migrations/versions/0006_teams_messages.py +27 -0
  31. app/migrations/versions/0007_tenant_scoping.py +50 -0
  32. app/migrations/versions/0008_policy_view.py +39 -0
  33. app/migrations/versions/0009_api_tokens.py +37 -0
  34. app/migrations/versions/0010_session_groups.py +23 -0
  35. app/migrations/versions/0011_budgets_and_evals.py +75 -0
  36. app/migrations/versions/0012_run_event_ts_index.py +23 -0
  37. app/migrations/versions/0013_agent_chains.py +102 -0
  38. app/migrations/versions/0014_dual_control.py +33 -0
  39. app/migrations/versions/0015_sampled_mode.py +24 -0
  40. app/migrations/versions/0016_sampling_salt.py +25 -0
  41. app/migrations/versions/0017_live_tokens_index.py +25 -0
  42. app/migrations/versions/0018_webhook_outbox.py +47 -0
  43. app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
  44. app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
  45. app/migrations/versions/0021_execution_grants.py +59 -0
  46. app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
  47. app/migrations/versions/0023_idempotency_keys.py +38 -0
  48. app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
  49. app/netguard.py +73 -0
  50. app/obslog.py +105 -0
  51. app/policy.py +390 -0
  52. app/ratelimit.py +78 -0
  53. app/routes/__init__.py +0 -0
  54. app/routes/admin.py +312 -0
  55. app/routes/agents.py +111 -0
  56. app/routes/approvals.py +576 -0
  57. app/routes/audit.py +213 -0
  58. app/routes/costgov.py +150 -0
  59. app/routes/health.py +53 -0
  60. app/routes/policy.py +72 -0
  61. app/routes/runs.py +115 -0
  62. app/routes/slack.py +40 -0
  63. app/routes/teams.py +29 -0
  64. app/routes/traces.py +55 -0
  65. app/schemas.py +248 -0
  66. app/security.py +203 -0
  67. app/siem.py +248 -0
  68. app/signing.py +342 -0
  69. app/sla.py +234 -0
  70. app/slack.py +282 -0
  71. app/teams.py +276 -0
  72. app/tokens.py +117 -0
  73. app/traces.py +309 -0
  74. app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  75. app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  76. app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  77. app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  78. app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  79. app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  80. app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  81. app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  82. app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  83. app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  84. app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  85. app/web-dist/assets/index-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/routes/admin.py ADDED
@@ -0,0 +1,312 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from pydantic import BaseModel, Field
5
+
6
+ from .. import __version__
7
+ from ..auth import (
8
+ admin_read_activity_enabled,
9
+ get_identity,
10
+ require_admin,
11
+ resolve_tenant,
12
+ )
13
+ from ..ledger.projection import rebuild_projection
14
+ from ..ledger.store import DEFAULT_AGENT
15
+ from ..security import is_secure
16
+
17
+ router = APIRouter(prefix="/api/admin", dependencies=[Depends(require_admin)])
18
+
19
+ # the advertised integrations and the SDK ranges each shim is contract-
20
+ # tested on (pins live in the shim pyprojects; this is what the console
21
+ # and the readiness checklist state to the operator)
22
+ SUPPORTED_SDKS = {
23
+ "openai-agents": ">=0.22,<1",
24
+ "langgraph": ">=1,<2",
25
+ "claude-agent-sdk": ">=0.2,<1",
26
+ "mcp": ">=1.23,<2",
27
+ }
28
+
29
+
30
+ class BackupVerified(BaseModel):
31
+ note: str = Field(default="", max_length=1024)
32
+
33
+
34
+ class IssueToken(BaseModel):
35
+ kind: str # service | admin
36
+ label: str = Field(default="", max_length=200)
37
+ # F24, service tokens only: the agent this token speaks for and the
38
+ # unit it stamps — the admin maps an agent to a unit right here
39
+ agent: str | None = Field(default=None, max_length=200)
40
+ unit: str | None = Field(default=None, max_length=200)
41
+
42
+
43
+ class ReadActivity(BaseModel):
44
+ """PUT /api/admin/read-activity — the separation-of-duties toggle."""
45
+ enabled: bool
46
+ reason: str = ""
47
+
48
+
49
+ async def _admin_actor(request: Request) -> str:
50
+ identity = await get_identity(request)
51
+ if identity:
52
+ return identity.get("email") or identity["subject"]
53
+ return "admin-token"
54
+
55
+
56
+ async def _record_admin_action(request: Request, action: str,
57
+ detail: dict) -> None:
58
+ """Administering the governance system is itself a chained, provable
59
+ event: the control plane audits its own operators. Lands in the
60
+ operator's tenant chain (resolved from the auth context, F20)."""
61
+ await request.app.state.ledger.append(
62
+ "admin_action",
63
+ {"action": action, "by": await _admin_actor(request), **detail},
64
+ tenant=await resolve_tenant(request))
65
+
66
+
67
+ @router.post("/rebuild-projection")
68
+ async def rebuild(request: Request):
69
+ """Re-fold the caller's tenant's projections from the log, proving they
70
+ are pure caches. Tenant-scoped: one tenant's admin never rebuilds
71
+ another tenant's cache (the F20/FOLLOWUPS seam, closed)."""
72
+ tenant = await resolve_tenant(request)
73
+ async with request.app.state.pool.connection() as conn:
74
+ async with conn.transaction():
75
+ await rebuild_projection(conn, tenant=tenant)
76
+ await _record_admin_action(request, "rebuild_projection", {})
77
+ return {"ok": True}
78
+
79
+
80
+ @router.post("/rotate-key")
81
+ async def rotate_key(request: Request):
82
+ """Rotate the Ed25519 signing key; old bundles keep verifying via the ring."""
83
+ signer = request.app.state.signer
84
+ if signer is None:
85
+ raise HTTPException(400, "signing is not configured")
86
+ old = signer.key_id
87
+ new = signer.rotate()
88
+ await _record_admin_action(request, "rotate_key",
89
+ {"old_key_id": old, "new_key_id": new})
90
+ return {"key_id": new}
91
+
92
+
93
+ @router.get("/settings")
94
+ async def settings_summary(request: Request):
95
+ """What is configured (never the secrets themselves)."""
96
+ s = request.app.state.settings
97
+ return {
98
+ "oidc": bool(s.oidc_issuer),
99
+ "rbac": s.rbac_enabled,
100
+ "slack": bool(s.slack_bot_token and s.slack_channel),
101
+ "service_token": bool(s.service_token),
102
+ "signing_key": bool(s.signing_key_path),
103
+ }
104
+
105
+
106
+ # -- F22: the config console (admin-only, read-mostly, mutations audited) -----
107
+
108
+
109
+ @router.get("/config")
110
+ async def config(request: Request):
111
+ """How the system is wired: integrations, chat surfaces, SIEM, signing
112
+ key ring — never a secret value. The registry lists the
113
+ frameworks that have checked in via ingested run events per tenant."""
114
+ s = request.app.state.settings
115
+ tenant = await resolve_tenant(request)
116
+ signer = request.app.state.signer
117
+
118
+ # frameworks that have actually governed a run in this tenant
119
+ async with request.app.state.pool.connection() as conn:
120
+ cur = await conn.execute(
121
+ "SELECT DISTINCT payload_json->>'framework' FROM events "
122
+ "WHERE tenant_id = %s AND type = 'approval_requested' "
123
+ "AND payload_json->>'framework' IS NOT NULL", (tenant,))
124
+ frameworks = sorted(r[0] for r in await cur.fetchall())
125
+
126
+ async with request.app.state.pool.connection() as conn:
127
+ cur = await conn.execute(
128
+ "SELECT count(*) FILTER (WHERE status = 'pending'), "
129
+ "count(*) FILTER (WHERE status = 'dead') "
130
+ "FROM webhook_outbox WHERE tenant_id = %s", (tenant,))
131
+ wh_pending, wh_dead = await cur.fetchone()
132
+ cur = await conn.execute(
133
+ "SELECT value, updated_ts_ms FROM config_view "
134
+ "WHERE tenant_id = %s AND key = 'backup_verified'", (tenant,))
135
+ backup = await cur.fetchone()
136
+
137
+ return {
138
+ # the eval-to-production seam, visible: the console's readiness
139
+ # checklist is driven from here, never from guesses
140
+ "insecure": bool(s.insecure),
141
+ # webhook durability: the outbox (pending = in flight or retrying,
142
+ # dead = exhausted retries, replayable)
143
+ "webhooks": {"pending": wh_pending, "dead": wh_dead,
144
+ "secret": bool(s.webhook_secret),
145
+ "attempts": s.webhook_attempts},
146
+ # the operator's attestation that a restore was exercised: the
147
+ # service cannot know, so the checklist asks and records the answer
148
+ "backups": {"verified_ts_ms": backup[1] if backup else None,
149
+ "note": (backup[0] or {}).get("note") if backup else None},
150
+ # transport posture: Secure/__Host- cookies and HSTS follow an https
151
+ # public URL (or PRIMARYPATH_SECURE_COOKIES); visible, never silent
152
+ "transport": {"secure_cookies": is_secure(s), "hsts": is_secure(s),
153
+ "public_url": s.public_url,
154
+ "max_body_bytes": s.max_body_bytes,
155
+ "agent_write_rate_per_minute":
156
+ s.agent_write_rate_per_minute},
157
+ "integrations": {
158
+ "frameworks_seen": frameworks, # drangue, mcp, langgraph, ...
159
+ "slack": bool(s.slack_bot_token and s.slack_channel),
160
+ "teams": bool(s.teams_app_id and s.teams_conversation_id),
161
+ # the framework SDK versions the shims are contract-tested
162
+ # against (server/tests/test_contract_sdks.py, run in CI)
163
+ "pinned": SUPPORTED_SDKS,
164
+ },
165
+ "auth": {
166
+ "oidc": bool(s.oidc_issuer), "rbac": s.rbac_enabled,
167
+ "service_token_env": bool(s.service_token),
168
+ "admin_token_env": bool(s.admin_token),
169
+ },
170
+ "siem": {"sink": s.siem,
171
+ "configured": s.siem != "none"},
172
+ # F23: the no-budget cost posture is a client risk choice — surfaced
173
+ # here so it is visible, never silent.
174
+ "cost": {"default_posture": s.cost_default_posture,
175
+ "default_on_exceed": s.cost_default_on_exceed,
176
+ "prices_configured": bool(s.model_prices)},
177
+ "signing": {"backend": s.signing_backend,
178
+ "key_id": signer.key_id if signer else None,
179
+ "ring": signer.pubring() if signer else {},
180
+ "backends": request.app.state.signers.backends()},
181
+ # execution grants ride the signing key: minted only when it exists
182
+ "grants": {"enabled": signer is not None, "ttl_s": s.grant_ttl_s},
183
+ # F24: the separation-of-duties posture, visible, never silent
184
+ "admin_can_read_activity": await admin_read_activity_enabled(
185
+ request, tenant),
186
+ "version": __version__,
187
+ }
188
+
189
+
190
+ @router.get("/tokens")
191
+ async def list_tokens(request: Request):
192
+ """Issued tokens: metadata only, never the token or its hash."""
193
+ return await request.app.state.tokens.list(
194
+ tenant=await resolve_tenant(request))
195
+
196
+
197
+ @router.post("/tokens")
198
+ async def issue_token(request: Request, body: IssueToken):
199
+ """Issue a service (agent plane) or admin (operator plane) token. The
200
+ plaintext is returned ONCE and never recoverable. Kinds stay distinct —
201
+ the token-split invariant. Audited.
202
+
203
+ A service token may carry {agent, unit}: the agent-to-unit map, stamped
204
+ server-side onto everything the agent writes (F24). Mapping an agent is
205
+ itself a governance event (agent_registered)."""
206
+ if body.kind not in ("service", "admin"):
207
+ raise HTTPException(400, "kind must be 'service' or 'admin'")
208
+ if body.kind != "service" and (body.agent or body.unit):
209
+ raise HTTPException(400, "agent/unit apply to service tokens only")
210
+ if body.unit and not body.agent:
211
+ raise HTTPException(400, "unit requires an agent")
212
+ if body.agent == DEFAULT_AGENT:
213
+ # "default" is the implicit agent of UNPINNED credentials (the env
214
+ # token, open dev mode): a token pinned to that name would share
215
+ # its chain with them and be indistinguishable in the audit. The
216
+ # ledger treats such a token as pinned regardless (pin-ness is
217
+ # credential metadata), but the name is refused up front.
218
+ raise HTTPException(
219
+ 400, f"'{DEFAULT_AGENT}' is the implicit agent of unpinned "
220
+ f"credentials; pin the token to a real agent name")
221
+ tenant = await resolve_tenant(request)
222
+ by = await _admin_actor(request)
223
+ token_id, plaintext = await request.app.state.tokens.issue(
224
+ body.kind, body.label, by=by, tenant=tenant,
225
+ agent_id=body.agent, unit=body.unit)
226
+ detail = {"token_id": token_id, "kind": body.kind, "label": body.label}
227
+ if body.agent:
228
+ detail["agent"] = body.agent
229
+ detail["unit"] = body.unit
230
+ await _record_admin_action(request, "issue_token", detail)
231
+ if body.agent and body.unit:
232
+ await request.app.state.agents.register(
233
+ body.agent, body.unit, by=by,
234
+ reason=f"service token {token_id} issued", tenant=tenant)
235
+ return {"id": token_id, "kind": body.kind, "token": plaintext,
236
+ "agent": body.agent, "unit": body.unit,
237
+ "note": "store this now; it is not recoverable"}
238
+
239
+
240
+ @router.get("/read-activity")
241
+ async def get_read_activity(request: Request):
242
+ """The separation-of-duties posture: may the admin role read agent
243
+ activity?"""
244
+ tenant = await resolve_tenant(request)
245
+ return {"enabled": await admin_read_activity_enabled(request, tenant)}
246
+
247
+
248
+ @router.put("/read-activity")
249
+ async def set_read_activity(request: Request, body: ReadActivity):
250
+ """Flip admin_can_read_activity — an event-sourced governance event, so
251
+ enabling break-glass is itself provable (and survives a projection
252
+ rebuild)."""
253
+ tenant = await resolve_tenant(request)
254
+ await request.app.state.ledger.append(
255
+ "admin_action",
256
+ {"action": "set_config", "key": "admin_can_read_activity",
257
+ "value": body.enabled, "by": await _admin_actor(request),
258
+ "reason": body.reason},
259
+ tenant=tenant)
260
+ return {"enabled": body.enabled}
261
+
262
+
263
+ @router.delete("/tokens/{token_id}")
264
+ async def revoke_token(request: Request, token_id: str):
265
+ """Revoke an issued token (tenant-scoped). Audited."""
266
+ tenant = await resolve_tenant(request)
267
+ ok = await request.app.state.tokens.revoke(token_id, tenant)
268
+ if not ok:
269
+ raise HTTPException(404, "unknown or already-revoked token")
270
+ await _record_admin_action(request, "revoke_token", {"token_id": token_id})
271
+ return {"ok": True}
272
+
273
+
274
+ # -- webhook outbox (F02): what was delivered, what is waiting, what died ----
275
+
276
+
277
+ @router.get("/webhooks")
278
+ async def list_webhooks(request: Request, status: str | None = None,
279
+ limit: int = 100):
280
+ """Delivery rows, newest first: attempts, next retry, last error.
281
+ `status` ∈ pending/delivered/dead."""
282
+ if status is not None and status not in ("pending", "delivered", "dead"):
283
+ raise HTTPException(400, "status must be pending, delivered or dead")
284
+ return await request.app.state.webhooks.list(
285
+ tenant=await resolve_tenant(request), status=status,
286
+ limit=max(1, min(limit, 500)))
287
+
288
+
289
+ @router.post("/webhooks/{delivery_id}/replay")
290
+ async def replay_webhook(request: Request, delivery_id: int):
291
+ """Re-queue a dead (or delivered) row. Audited."""
292
+ tenant = await resolve_tenant(request)
293
+ ok = await request.app.state.webhooks.replay(delivery_id, tenant)
294
+ if not ok:
295
+ raise HTTPException(404, "unknown delivery, or still pending")
296
+ await _record_admin_action(request, "replay_webhook",
297
+ {"delivery_id": delivery_id})
298
+ return {"ok": True}
299
+
300
+
301
+ @router.put("/backup-verified")
302
+ async def backup_verified(request: Request, body: BackupVerified):
303
+ """The operator attests a restore was exercised (date + note). An
304
+ audited governance event; the readiness checklist reads it back."""
305
+ tenant = await resolve_tenant(request)
306
+ await request.app.state.ledger.append(
307
+ "admin_action",
308
+ {"action": "set_config", "key": "backup_verified",
309
+ "value": {"note": body.note}, "by": await _admin_actor(request),
310
+ "reason": "backup restore verified"},
311
+ tenant=tenant)
312
+ return {"ok": True}
app/routes/agents.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from pydantic import BaseModel, Field
5
+
6
+ from ..agents import CAPTURES, SIGNING_BACKENDS
7
+ from ..auth import (
8
+ activity_scope,
9
+ admin_read_activity_enabled,
10
+ get_identity,
11
+ require_admin,
12
+ require_reader,
13
+ resolve_tenant,
14
+ )
15
+
16
+ router = APIRouter(prefix="/api")
17
+
18
+
19
+ class AgentUpdate(BaseModel):
20
+ """PUT /api/agents/{agent} — map an agent to a unit and/or set its
21
+ evidence profile. Every change is an audited governance event."""
22
+ unit: str | None = Field(default=None, max_length=200)
23
+ signing_backend: str | None = None # default | none | file | kms
24
+ key_id: str | None = None
25
+ capture: str | None = None # none | decisions | full
26
+ retention_days: int | None = Field(default=None, ge=0)
27
+ reason: str = ""
28
+
29
+
30
+ async def _actor(request: Request) -> str:
31
+ identity = await get_identity(request)
32
+ if identity:
33
+ return identity.get("email") or identity["subject"]
34
+ return "admin-token"
35
+
36
+
37
+ @router.get("/agents", dependencies=[Depends(require_admin)])
38
+ async def list_agents(request: Request):
39
+ """Every agent with its unit and evidence profile (admin). The registry
40
+ is metadata about the system, not agent activity — the operator manages
41
+ the map without reading the work."""
42
+ return await request.app.state.agents.agents(
43
+ tenant=await resolve_tenant(request))
44
+
45
+
46
+ @router.put("/agents/{agent}", dependencies=[Depends(require_admin)])
47
+ async def update_agent(request: Request, agent: str, body: AgentUpdate):
48
+ """Set an agent's unit and/or evidence profile -> agent_registered /
49
+ evidence_set on the governance chain."""
50
+ if body.signing_backend is not None \
51
+ and body.signing_backend not in SIGNING_BACKENDS:
52
+ raise HTTPException(
53
+ 400, f"signing_backend must be one of {SIGNING_BACKENDS}")
54
+ if body.capture is not None and body.capture not in CAPTURES:
55
+ raise HTTPException(400, f"capture must be one of {CAPTURES}")
56
+
57
+ tenant = await resolve_tenant(request)
58
+ registry = request.app.state.agents
59
+ by = await _actor(request)
60
+ sent = body.model_fields_set
61
+
62
+ if "unit" in sent:
63
+ await registry.register(agent, body.unit, by=by, reason=body.reason,
64
+ tenant=tenant)
65
+ if sent & {"signing_backend", "key_id", "capture", "retention_days"}:
66
+ current = await registry.evidence_for(agent, tenant)
67
+ await registry.set_evidence(
68
+ agent,
69
+ signing_backend=body.signing_backend
70
+ or current["signing_backend"],
71
+ key_id=body.key_id if "key_id" in sent else current["key_id"],
72
+ capture=body.capture or current["capture"],
73
+ retention_days=body.retention_days if "retention_days" in sent
74
+ else current["retention_days"],
75
+ by=by, reason=body.reason, tenant=tenant)
76
+
77
+ return {
78
+ "agent": agent,
79
+ "unit": await registry.unit_of(agent, tenant),
80
+ "evidence": await registry.evidence_for(agent, tenant),
81
+ }
82
+
83
+
84
+ @router.get("/viewer", dependencies=[Depends(require_reader)])
85
+ async def viewer(request: Request):
86
+ """The caller's need-to-know context, for the console: which units it
87
+ can see, whether the admin separation-of-duties lock applies, and
88
+ whether reading as an admin would be recorded (break-glass)."""
89
+ tenant = await resolve_tenant(request)
90
+ # scope only describes what a read WOULD see; it is not itself an
91
+ # activity read, so it never trips the break-glass record
92
+ scope = await activity_scope(request, record=False)
93
+ can_read = await admin_read_activity_enabled(request, tenant)
94
+ if scope is None:
95
+ return {"units": None, "visible_units": None, "units_in_force": False,
96
+ "role": None, "locked": False,
97
+ "admin_can_read_activity": can_read}
98
+ known = await request.app.state.agents.known_units(tenant)
99
+ visible = sorted(scope["units"] & known)
100
+ # locked = separation of duties is actually shutting this admin out:
101
+ # units are in force in this tenant and none of theirs match
102
+ locked = scope["locked"]
103
+ if scope.get("role") == "admin" and not can_read and not locked:
104
+ locked = bool(known) and not visible
105
+ return {"units": sorted(scope["units"]),
106
+ "visible_units": visible,
107
+ "units_in_force": bool(known),
108
+ "agent": scope.get("agent"),
109
+ "role": scope.get("role"),
110
+ "locked": locked,
111
+ "admin_can_read_activity": can_read}