keywright-server 0.0.1__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.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import secrets
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv: list[str] | None = None) -> int:
|
|
10
|
+
p = argparse.ArgumentParser(prog="keywright-server", description="Keywright server: JIT credential broker for AI agents")
|
|
11
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
12
|
+
s = sub.add_parser("serve", help="run the server")
|
|
13
|
+
s.add_argument("--host")
|
|
14
|
+
s.add_argument("--port", type=int)
|
|
15
|
+
a = sub.add_parser("create-agent", help="create an agent and print its token once")
|
|
16
|
+
a.add_argument("name")
|
|
17
|
+
sub.add_parser("set-admin-password", help="set/reset the admin UI password (prompts)")
|
|
18
|
+
args = p.parse_args(argv)
|
|
19
|
+
|
|
20
|
+
from .config import settings
|
|
21
|
+
|
|
22
|
+
if args.cmd == "serve":
|
|
23
|
+
import uvicorn
|
|
24
|
+
|
|
25
|
+
from .web.app import create_app
|
|
26
|
+
|
|
27
|
+
uvicorn.run(create_app(), host=args.host or settings.host, port=args.port or settings.port, log_level="info",
|
|
28
|
+
forwarded_allow_ips=settings.trusted_proxies or None)
|
|
29
|
+
return 0
|
|
30
|
+
if args.cmd == "create-agent":
|
|
31
|
+
return asyncio.run(_create_agent(args.name))
|
|
32
|
+
if args.cmd == "set-admin-password":
|
|
33
|
+
import getpass
|
|
34
|
+
|
|
35
|
+
pw = getpass.getpass("New admin password: ")
|
|
36
|
+
if pw != getpass.getpass("Repeat: "):
|
|
37
|
+
print("passwords differ", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
return asyncio.run(_set_password(pw))
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _create_agent(name: str) -> int:
|
|
44
|
+
from .db import init_db, session
|
|
45
|
+
from .mcp_server import hash_token
|
|
46
|
+
from .models import Agent
|
|
47
|
+
|
|
48
|
+
await init_db()
|
|
49
|
+
token = "kw_" + secrets.token_urlsafe(32)
|
|
50
|
+
async with session() as db:
|
|
51
|
+
db.add(Agent(name=name, token_hash=hash_token(token), token_prefix=token[:8]))
|
|
52
|
+
await db.commit()
|
|
53
|
+
print(token)
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _set_password(pw: str) -> int:
|
|
58
|
+
from .db import init_db, session
|
|
59
|
+
from .settings_store import warm
|
|
60
|
+
from .web.auth import set_password
|
|
61
|
+
|
|
62
|
+
await init_db()
|
|
63
|
+
async with session() as db:
|
|
64
|
+
await warm(db)
|
|
65
|
+
await set_password(db, pw)
|
|
66
|
+
print("ok")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
sys.exit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Operation constraints shared by decisions, enforcement, and presentation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def operation_detail(detail: dict | None) -> dict:
|
|
10
|
+
"""The single operation an automatic request was made for; empty for pattern requests and for
|
|
11
|
+
batched requests, whose operations come from `operations()`."""
|
|
12
|
+
if "operations" in (detail or {}):
|
|
13
|
+
return {}
|
|
14
|
+
# Requests from before constraints recorded user=None for the default account; a non-default
|
|
15
|
+
# account always carries its own ssh/<host>/user/<name> pattern, so dropping the key is safe.
|
|
16
|
+
return {k: v for k, v in (detail or {}).items()
|
|
17
|
+
if k not in ("bundles", "hazards") and not (k == "user" and v is None)}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def operations(detail: dict | None) -> list[tuple[list[str] | None, dict]]:
|
|
21
|
+
"""(patterns, operation) pairs a decision binds to. A batched access_request stores one entry
|
|
22
|
+
per operation with the patterns it needs; an automatic request has one operation and the
|
|
23
|
+
request's own patterns (None here). Hazard flags travel with the operation for display but are
|
|
24
|
+
never part of what a grant binds to."""
|
|
25
|
+
if "operations" in (detail or {}):
|
|
26
|
+
return [(list(o["patterns"]), {k: v for k, v in o["operation"].items() if k != "hazards"})
|
|
27
|
+
for o in detail["operations"]]
|
|
28
|
+
op = operation_detail(detail)
|
|
29
|
+
return [(None, op)] if op else []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def binds_exactly(detail: dict | None) -> bool:
|
|
33
|
+
return bool(operations(detail))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def path_scoped(detail: dict | None) -> bool:
|
|
37
|
+
"""A single file write. Its pattern already names host and path, so approving the patterns for
|
|
38
|
+
a short time means "any contents at this path, briefly": bounded in time instead of pinned to
|
|
39
|
+
a hash, which is what iterating on a script needs."""
|
|
40
|
+
return bool(detail) and "sha256" in detail and "operations" not in detail
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def hazards_of(detail: dict | None) -> list[dict]:
|
|
44
|
+
"""Every hazard flag on a request or operation, whichever shape it has."""
|
|
45
|
+
if not detail:
|
|
46
|
+
return []
|
|
47
|
+
if "operations" in detail:
|
|
48
|
+
out: list[dict] = []
|
|
49
|
+
for o in detail["operations"]:
|
|
50
|
+
for h in (o.get("operation") or {}).get("hazards") or []:
|
|
51
|
+
if h not in out:
|
|
52
|
+
out.append(h)
|
|
53
|
+
return out
|
|
54
|
+
return list(detail.get("hazards") or [])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hazard_level(detail: dict | None) -> str | None:
|
|
58
|
+
from keywright.hazards import worst
|
|
59
|
+
|
|
60
|
+
return worst(hazards_of(detail))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def binds_operation(constraints: dict | None) -> bool:
|
|
64
|
+
"""Whether constraints pin one exact operation (command, file digest, path or tool arguments),
|
|
65
|
+
as opposed to only the target identity."""
|
|
66
|
+
return bool(constraints) and any(k not in TARGET_KEYS for k in constraints)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
TARGET_KEYS = ("resource_id", "host", "user", "server", "gateway", "to_resource_id", "to_host", "to_user")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def target_of(resource) -> dict:
|
|
73
|
+
"""What a call with no operation (access_request, tools/list) is compared against: rules bound
|
|
74
|
+
only to this target apply, rules bound to an exact command or arguments do not."""
|
|
75
|
+
if resource.kind == "ssh":
|
|
76
|
+
return {"resource_id": resource.id, "host": resource.name, "user": resource.config.get("user") or "root"}
|
|
77
|
+
if resource.kind == "llm":
|
|
78
|
+
return {"resource_id": resource.id, "gateway": resource.name}
|
|
79
|
+
return {"resource_id": resource.id, "server": resource.name}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def constraints_for(detail: dict | None, extent: str) -> dict | None:
|
|
83
|
+
return operation_constraints(operation_detail(detail), extent)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def operation_constraints(operation: dict | None, extent: str) -> dict | None:
|
|
87
|
+
if not operation:
|
|
88
|
+
return None
|
|
89
|
+
if extent == "exact":
|
|
90
|
+
return operation
|
|
91
|
+
# Broader command/tool patterns still bind to the target identity and SSH account.
|
|
92
|
+
return {k: v for k, v in operation.items() if k in TARGET_KEYS} or None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def operation_matches(constraints: dict | None, operation: dict | None) -> bool:
|
|
96
|
+
if not constraints:
|
|
97
|
+
return True
|
|
98
|
+
if operation is None or not constraints.keys() <= operation.keys():
|
|
99
|
+
return False
|
|
100
|
+
# JSON equality must distinguish true from 1, unlike Python container equality.
|
|
101
|
+
try:
|
|
102
|
+
return json.dumps(constraints, sort_keys=True, allow_nan=False) == json.dumps(
|
|
103
|
+
{k: operation[k] for k in constraints}, sort_keys=True, allow_nan=False)
|
|
104
|
+
except ValueError:
|
|
105
|
+
# NaN and Infinity are not JSON and never equal themselves, so they match nothing.
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def constraint_label(constraints: dict[str, Any] | None) -> str:
|
|
110
|
+
if not constraints:
|
|
111
|
+
return "Permission patterns · any matching arguments"
|
|
112
|
+
if "command" in constraints:
|
|
113
|
+
return "Exact command"
|
|
114
|
+
if "arguments" in constraints:
|
|
115
|
+
return "Exact tool arguments"
|
|
116
|
+
if "sha256" in constraints:
|
|
117
|
+
return "Exact file contents"
|
|
118
|
+
if "path" in constraints:
|
|
119
|
+
return "Exact path"
|
|
120
|
+
if "user" in constraints:
|
|
121
|
+
return f"Permission patterns · as {constraints['user']}"
|
|
122
|
+
return "Permission patterns · bound to this target"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def grant_constraints(grant, request=None) -> dict | None:
|
|
126
|
+
"""Legacy automatic once grants bind through their request rather than a stored constraint."""
|
|
127
|
+
if grant.constraints:
|
|
128
|
+
return grant.constraints
|
|
129
|
+
if grant.scope == "once" and request and request.decision_options is None:
|
|
130
|
+
return operation_detail(request.detail) or None
|
|
131
|
+
return None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Human-readable access inventory, including all authorization sources and restrictions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from keywright.patterns import covers, overlaps
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
|
|
11
|
+
from .access import constraint_label, grant_constraints
|
|
12
|
+
from .models import AccessRequest, Agent, Grant, Policy, Resource, now
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def remaining(expires_at, at) -> str:
|
|
16
|
+
if expires_at is None:
|
|
17
|
+
return "Until used or revoked"
|
|
18
|
+
seconds = (expires_at - at).total_seconds()
|
|
19
|
+
return "Expired" if seconds <= 0 else f"{max(1, math.ceil(seconds / 60))}m remaining"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def overview(db) -> dict:
|
|
23
|
+
at = now()
|
|
24
|
+
agents = (await db.execute(select(Agent).order_by(Agent.name))).scalars().all()
|
|
25
|
+
resources = (await db.execute(select(Resource).order_by(Resource.kind, Resource.name))).scalars().all()
|
|
26
|
+
policies = (await db.execute(select(Policy).order_by(Policy.pattern))).scalars().all()
|
|
27
|
+
# Spent and expired grants are never revoked, so they pile up; leave them in the database.
|
|
28
|
+
grants = (await db.execute(select(Grant).where(
|
|
29
|
+
Grant.revoked_at.is_(None),
|
|
30
|
+
(Grant.expires_at.is_(None)) | (Grant.expires_at > at),
|
|
31
|
+
(Grant.uses_remaining.is_(None)) | (Grant.uses_remaining > 0),
|
|
32
|
+
).order_by(Grant.created_at.desc()))).scalars().all()
|
|
33
|
+
grants = [g for g in grants if g.is_active(at, g.session_id)]
|
|
34
|
+
request_ids = {g.request_id for g in grants if g.request_id}
|
|
35
|
+
requests = {r.id: r for r in (await db.execute(select(AccessRequest).where(
|
|
36
|
+
AccessRequest.id.in_(request_ids)))).scalars().all()} if request_ids else {}
|
|
37
|
+
groups = defaultdict(list)
|
|
38
|
+
for g in grants:
|
|
39
|
+
groups[(g.agent_id, g.request_id or g.id)].append(g)
|
|
40
|
+
leases = []
|
|
41
|
+
for (agent_id, _), members in groups.items():
|
|
42
|
+
first = members[0]
|
|
43
|
+
req = requests.get(first.request_id)
|
|
44
|
+
leases.append({"agent_id": agent_id, "request": req, "grants": members,
|
|
45
|
+
"constraints": grant_constraints(first, req),
|
|
46
|
+
"label": constraint_label(grant_constraints(first, req)),
|
|
47
|
+
"remaining": remaining(first.expires_at, at), "scope": first.scope,
|
|
48
|
+
"session_id": first.session_id})
|
|
49
|
+
# Match every rule and grant against every target once, not once per agent.
|
|
50
|
+
scopes = {res.id: res.path_prefix + "/**" for res in resources}
|
|
51
|
+
rules_on = {rid: [p for p in policies if overlaps(p.pattern, scope)] for rid, scope in scopes.items()}
|
|
52
|
+
grants_on = {rid: [g for g in grants if overlaps(g.pattern, scope)] for rid, scope in scopes.items()}
|
|
53
|
+
by_agent = []
|
|
54
|
+
for agent in agents:
|
|
55
|
+
targets = []
|
|
56
|
+
for res in resources:
|
|
57
|
+
scope = scopes[res.id]
|
|
58
|
+
relevant = [p for p in rules_on[res.id] if p.agent_id in (None, agent.id)]
|
|
59
|
+
allows = [p for p in relevant if p.effect == "allow"]
|
|
60
|
+
denies = [p for p in relevant if p.effect == "deny"]
|
|
61
|
+
temporary = [g for g in grants_on[res.id] if g.agent_id == agent.id]
|
|
62
|
+
if agent.disabled:
|
|
63
|
+
state = "Suspended · new operations blocked"
|
|
64
|
+
elif any(not p.constraints and covers(p.pattern, scope) for p in denies):
|
|
65
|
+
state = "Denied for this target"
|
|
66
|
+
elif res.default_mode == "open":
|
|
67
|
+
state = "Allowed by default"
|
|
68
|
+
elif allows or temporary:
|
|
69
|
+
state = "Scoped access"
|
|
70
|
+
elif res.default_mode == "locked":
|
|
71
|
+
state = "Locked · standing rule required"
|
|
72
|
+
else:
|
|
73
|
+
state = "Approval required"
|
|
74
|
+
targets.append({"resource": res, "state": state, "allows": allows, "denies": denies,
|
|
75
|
+
"temporary": len(temporary), "has_restrictions": bool(denies)})
|
|
76
|
+
by_agent.append({"agent": agent, "targets": targets,
|
|
77
|
+
"leases": [g for g in leases if g["agent_id"] == agent.id]})
|
|
78
|
+
return {"access_agents": by_agent, "lease_count": len(leases), "target_count": len(resources),
|
|
79
|
+
"agent_count": len(agents)}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---- the Grants page: agent -> resource -> approval, newest first ---------------------------------
|
|
83
|
+
def grant_groups(grants: list[Grant], agents: dict[str, Agent], requests: dict[str, AccessRequest], at,
|
|
84
|
+
*, include_inactive: bool = False) -> list[dict]:
|
|
85
|
+
"""Grants grouped the way a human reads them: by agent, then by the host or server they reach,
|
|
86
|
+
then by the approval that made them (one decision can grant several paths). Every level is
|
|
87
|
+
ordered by its most recent approval. Inactive grants are left out unless asked for."""
|
|
88
|
+
from keywright.patterns import resource_of
|
|
89
|
+
|
|
90
|
+
from . import approvals
|
|
91
|
+
|
|
92
|
+
def state(g: Grant) -> str:
|
|
93
|
+
if g.is_active(at, g.session_id):
|
|
94
|
+
return "active"
|
|
95
|
+
return "revoked" if g.revoked_at else "ended"
|
|
96
|
+
|
|
97
|
+
kept = [g for g in grants if include_inactive or state(g) == "active"]
|
|
98
|
+
request_resources: dict[str, set[str]] = defaultdict(set)
|
|
99
|
+
for g in grants:
|
|
100
|
+
if g.request_id and state(g) == "active":
|
|
101
|
+
request_resources[g.request_id].add("/".join(resource_of(g.pattern) or ("?", "?")))
|
|
102
|
+
|
|
103
|
+
tree: dict[str, dict[str, dict[str, list[Grant]]]] = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
|
|
104
|
+
for g in kept:
|
|
105
|
+
res = "/".join(resource_of(g.pattern) or ("?", g.pattern))
|
|
106
|
+
tree[g.agent_id][res][g.request_id or f"grant:{g.id}"].append(g)
|
|
107
|
+
|
|
108
|
+
def latest(gs):
|
|
109
|
+
return max(g.created_at for g in gs)
|
|
110
|
+
|
|
111
|
+
out = []
|
|
112
|
+
for agent_id, by_res in tree.items():
|
|
113
|
+
hosts = []
|
|
114
|
+
for res, by_req in by_res.items():
|
|
115
|
+
approvals_ = []
|
|
116
|
+
for key, gs in by_req.items():
|
|
117
|
+
gs.sort(key=lambda g: g.pattern)
|
|
118
|
+
req = requests.get(gs[0].request_id) if gs[0].request_id else None
|
|
119
|
+
states = {state(g) for g in gs}
|
|
120
|
+
st = "active" if "active" in states else ("revoked" if states == {"revoked"} else "ended")
|
|
121
|
+
expiry = max((g.expires_at for g in gs if g.expires_at), default=None)
|
|
122
|
+
elsewhere = len(request_resources.get(gs[0].request_id or "", set()) - {res})
|
|
123
|
+
approvals_.append({
|
|
124
|
+
"key": key, "grants": gs, "request": req, "approved_at": latest(gs), "state": st,
|
|
125
|
+
"label": approvals.decision_label(req) if req else approvals.SCOPE_LABELS.get(gs[0].scope, gs[0].scope),
|
|
126
|
+
"remaining": remaining(expiry, at) if st == "active" else None,
|
|
127
|
+
"uses": [g.uses_remaining for g in gs if g.uses_remaining is not None],
|
|
128
|
+
"session": next((g.session_id for g in gs if g.session_id), None),
|
|
129
|
+
"constraints": [(g, grant_constraints(g, req)) for g in gs],
|
|
130
|
+
"revoke": (f"/ui/requests/{req.id}/revoke" if req else f"/ui/grants/{gs[0].id}/revoke")
|
|
131
|
+
if st == "active" else None,
|
|
132
|
+
"elsewhere": elsewhere,
|
|
133
|
+
})
|
|
134
|
+
approvals_.sort(key=lambda a: a["approved_at"], reverse=True)
|
|
135
|
+
kind, _, name = res.partition("/")
|
|
136
|
+
hosts.append({"resource": res, "kind": kind, "name": name, "approvals": approvals_,
|
|
137
|
+
"latest": approvals_[0]["approved_at"],
|
|
138
|
+
"active": sum(1 for a in approvals_ if a["state"] == "active")})
|
|
139
|
+
hosts.sort(key=lambda h: h["latest"], reverse=True)
|
|
140
|
+
agent = agents.get(agent_id)
|
|
141
|
+
out.append({"agent": agent, "agent_id": agent_id, "name": agent.name if agent else "?", "hosts": hosts,
|
|
142
|
+
"latest": hosts[0]["latest"], "active": sum(h["active"] for h in hosts)})
|
|
143
|
+
out.sort(key=lambda a: a["latest"], reverse=True)
|
|
144
|
+
return out
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Compact authenticated actions for notification buttons: `<BODY>.<mac>`.
|
|
2
|
+
|
|
3
|
+
A phone renders a button as a short opaque string and hands it back when tapped. The MAC is what
|
|
4
|
+
carries the authority: knowing a request or nudge ID is not enough to act on one, and the strings
|
|
5
|
+
stay short enough for Android's action payload limits, which a signed token would not.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
|
|
14
|
+
from .config import settings
|
|
15
|
+
|
|
16
|
+
MAC_BYTES = 16 # 128 bits, the same budget itsdangerous spends, in a third of the characters
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sign(body: str) -> str:
|
|
20
|
+
mac = hmac.new(settings.secret_key(), body.encode(), hashlib.sha256).digest()[:MAC_BYTES]
|
|
21
|
+
return body + "." + base64.urlsafe_b64encode(mac).decode().rstrip("=")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def verify(action: str) -> str | None:
|
|
25
|
+
"""The body of an action we signed, or None. Constant-time; never raises."""
|
|
26
|
+
try:
|
|
27
|
+
body, _ = action.rsplit(".", 1)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return None
|
|
30
|
+
return body if hmac.compare_digest(action, sign(body)) else None
|