agentgov-core 0.1.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.
File without changes
agentgov_core/cedar.py ADDED
@@ -0,0 +1,56 @@
1
+ """cedar-agent client (permitio/cedar-agent HTTP API).
2
+
3
+ Runs schemaless locally: we load the policy set and evaluate with an inline context
4
+ carrying { employment, classification }. A Cedar *forbid* overrides *permit*, which is
5
+ how the contractor+RESTRICTED denial works.
6
+ """
7
+ import re
8
+
9
+ import httpx
10
+
11
+ from .config import settings
12
+
13
+ TIMEOUT = 10.0
14
+
15
+
16
+ def split_statements(text: str) -> list[str]:
17
+ """cedar-agent stores ONE Cedar statement per policy entry. Strip // comments and
18
+ split the policy set on statement terminators (';' only ends statements here)."""
19
+ no_comments = "\n".join(re.sub(r"//.*$", "", line) for line in text.splitlines())
20
+ return [s.strip() + ";" for s in no_comments.split(";") if s.strip()]
21
+
22
+
23
+ class Cedar:
24
+ def __init__(self, base_url=None):
25
+ self.base = (base_url or settings.CEDAR_URL).rstrip("/")
26
+
27
+ def load_policies(self, policy_text: str | None = None):
28
+ """Replace the cedar-agent policy set with services/policy/policies.cedar."""
29
+ if policy_text is None:
30
+ with open(settings.CEDAR_POLICIES_PATH) as f:
31
+ policy_text = f.read()
32
+ # cedar-agent stores a list of {id, content}, one statement each. PUT replaces the set.
33
+ body = [{"id": f"agentgov_{i}", "content": s} for i, s in enumerate(split_statements(policy_text))]
34
+ r = httpx.put(f"{self.base}/v1/policies", json=body, timeout=TIMEOUT)
35
+ r.raise_for_status()
36
+ return r.json()
37
+
38
+ def is_authorized(self, principal_id: str, action: str, resource_id: str, context: dict,
39
+ resource_type: str = "Agent") -> bool:
40
+ """Return True on Allow. action is 'discover' | 'use'. ``resource_type`` is ``Agent``
41
+ or (Phase 2) ``McpServer`` — the classification/contractor policies are written
42
+ resource-agnostic on purpose, so a governed MCP server is judged by exactly the same
43
+ rules as an agent."""
44
+ body = {
45
+ "principal": f'User::"{principal_id}"',
46
+ "action": f'Action::"{action}"',
47
+ "resource": f'{resource_type}::"{resource_id}"',
48
+ "context": context,
49
+ }
50
+ r = httpx.post(f"{self.base}/v1/is_authorized", json=body, timeout=TIMEOUT)
51
+ r.raise_for_status()
52
+ decision = r.json().get("decision", "Deny")
53
+ return str(decision).lower() == "allow"
54
+
55
+
56
+ cedar = Cedar()
@@ -0,0 +1,128 @@
1
+ """Runtime configuration for registry-api (env-driven; local defaults for docker-compose)."""
2
+ import os
3
+
4
+
5
+ class Settings:
6
+ # Path-prefix awareness (BUILD_PLAN §9). Locally the api answers at /assets/registry/api.
7
+ ROOT_PATH = os.getenv("ROOT_PATH", "/assets/registry/api")
8
+
9
+ # Postgres (spine).
10
+ DB_URL = os.getenv(
11
+ "DB_URL", "postgresql://agentgov:dev@localhost:5432/agentgov"
12
+ )
13
+
14
+ # OpenFGA (spine). Store is resolved by name so ids need not be plumbed around.
15
+ OPENFGA_API_URL = os.getenv("OPENFGA_API_URL", "http://localhost:8080")
16
+ OPENFGA_STORE_NAME = os.getenv("OPENFGA_STORE_NAME", "agentgov")
17
+
18
+ # Cedar (spine).
19
+ CEDAR_URL = os.getenv("CEDAR_URL", "http://localhost:8180")
20
+ # Local-dev-only convenience default (Cedar.load_policies() reads this when called with no
21
+ # explicit policy_text, which is how registry-api's own startup calls it — see main.py).
22
+ # `services/policy/` is an outer-repo fixture this shared package has no business locating via
23
+ # __file__ trickery (that broke when this module moved out of services/registry-api/app/ in
24
+ # Phase 5 §5.8 — __file__ no longer sits inside the repo tree at a fixed depth). Relative-to-cwd
25
+ # is correct here because run_phase*.sh always launches registry-api's uvicorn process with cwd
26
+ # = services/registry-api (`cd services/registry-api && ... exec uvicorn ...`), never repo root.
27
+ # NOT reachable at all inside the deployed Docker image today (services/policy/ isn't copied in)
28
+ # — pre-existing gap, not introduced or fixed by this change; real deployments would need it
29
+ # added to the Dockerfile if/when this startup call needs to succeed there too.
30
+ CEDAR_POLICIES_PATH = os.getenv("CEDAR_POLICIES_PATH", "../policy/policies.cedar")
31
+
32
+ # Local mock-Okta JWT verification. In real Okta this is a JWKS URL; locally we verify
33
+ # against a PEM public key on disk (no network dependency for the demo). Same cwd-relative
34
+ # reasoning as CEDAR_POLICIES_PATH above — real deployments never hit this path at all, they
35
+ # set JWT_JWKS_URL instead.
36
+ JWT_PUBLIC_KEY_PATH = os.getenv("JWT_PUBLIC_KEY_PATH", "../../local/identity/keys/public.pem")
37
+ JWT_ISSUER = os.getenv("JWT_ISSUER", "https://mock-okta.receptor.local/oauth2")
38
+ JWT_AUDIENCE = os.getenv("JWT_AUDIENCE", "agent-registry")
39
+ # Real Okta: set JWT_JWKS_URL to the authz server's JWKS
40
+ # (https://<domain>/oauth2/<authServerId>/v1/keys) and JWT_ISSUER to its issuer.
41
+ # When set, tokens are verified against Okta's rotating keys instead of the local PEM;
42
+ # when empty (default), the local mock PEM path is used. Nothing else changes.
43
+ JWT_JWKS_URL = os.getenv("JWT_JWKS_URL", "")
44
+ # Allow disabling signature verification only for offline unit poking; default on.
45
+ JWT_VERIFY = os.getenv("JWT_VERIFY", "1") == "1"
46
+
47
+ # OTel. If no OTLP endpoint is set we still emit spans to stdout (ConsoleSpanExporter)
48
+ # so the Phase-0 DoD ("discover emits a trace w/ authz span-event") is observable locally.
49
+ OTLP_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
50
+ OTEL_CONSOLE = os.getenv("OTEL_CONSOLE", "1") == "1"
51
+ SERVICE_NAME = os.getenv("OTEL_SERVICE_NAME", "registry-api")
52
+ # Phase 3 — the SDK default (60s) makes SigNoz-backed usage assertions slow to observe
53
+ # locally; the local test harness shortens this the same way it shortens ACCESS_TTL_MINUTES.
54
+ OTEL_METRIC_EXPORT_INTERVAL_MS = int(os.getenv("OTEL_METRIC_EXPORT_INTERVAL_MS", "60000"))
55
+
56
+ # Phase 1 — Temporal (spine).
57
+ TEMPORAL_ADDRESS = os.getenv("TEMPORAL_ADDRESS", "localhost:7233")
58
+ TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "agentgov-access")
59
+
60
+ # Phase 1 — ServiceNow. Local dev talks to the mock shim (local/servicenow_mock);
61
+ # swap SERVICENOW_URL to the real PDI once provisioned, hook contract stays identical.
62
+ SERVICENOW_URL = os.getenv("SERVICENOW_URL", "http://localhost:8300")
63
+ # Shared secret authenticating the ServiceNow -> registry-api webhook (dev-only default;
64
+ # real deployments source this from AWS Secrets Manager per CLAUDE.md).
65
+ SERVICENOW_WEBHOOK_SECRET = os.getenv("SERVICENOW_WEBHOOK_SECRET", "local-dev-servicenow-secret")
66
+
67
+ # Governance policy knobs (BUILD_PLAN Phase 1 DoD: "10-min TTL expiry"). Configurable via
68
+ # env so the local test harness can shrink them for fast iteration; the demo/prod default
69
+ # is the real 10-minute TTL. Never accepted as a per-request client override.
70
+ ACCESS_TTL_MINUTES = float(os.getenv("ACCESS_TTL_MINUTES", "10"))
71
+ ACCESS_SLA_MINUTES = float(os.getenv("ACCESS_SLA_MINUTES", "60"))
72
+
73
+ # ---------------------------------------------------------------- Phase 1 seam: real ServiceNow
74
+ # Table-API auth for the real PDI (docs/setup/servicenow.md §4/§6). Empty = the local mock,
75
+ # which needs no auth and returns bare records; set both to switch to the real instance.
76
+ SERVICENOW_USER = os.getenv("SERVICENOW_USER", "")
77
+ SERVICENOW_PASSWORD = os.getenv("SERVICENOW_PASSWORD", "")
78
+
79
+ # ---------------------------------------------------------------- Phase 2: four hosts
80
+ # 'mock' points every adapter at local/hosts_mock (same contract, no cloud spend);
81
+ # 'real' points at the platforms themselves. Only URLs/credentials differ — BUILD_PLAN §Phase 2.
82
+ HOSTS_MODE = os.getenv("AGENTGOV_HOSTS_MODE", "mock")
83
+ HOSTS_MOCK_URL = os.getenv("AGENTGOV_HOSTS_MOCK_URL", "http://localhost:8400")
84
+
85
+ LITELLM_URL = os.getenv("LITELLM_URL", "") # real: http://litellm:4000
86
+ LITELLM_MASTER_KEY = os.getenv("LITELLM_MASTER_KEY", "sk-agentgov-local-master")
87
+
88
+ # AgentCore: the GATEWAY's credentials. Never the user's, and never a static key in real mode —
89
+ # app/hosts/agentcore.py::_gateway_credentials() resolves the ECS task role's temporary STS
90
+ # credentials from the ambient AWS chain instead (§5.7). These two are MOCK-ONLY fallbacks
91
+ # (local/hosts_mock doesn't touch real AWS, so fixed demo values are fine there).
92
+ AGENTCORE_URL = os.getenv("AGENTCORE_URL", "")
93
+ AGENTCORE_REGION = os.getenv("AGENTCORE_REGION", "us-east-1")
94
+ AGENTCORE_ACCESS_KEY_ID = os.getenv("AGENTCORE_ACCESS_KEY_ID", "AKIAAGENTGOVDEMO0000")
95
+ AGENTCORE_SECRET_ACCESS_KEY = os.getenv("AGENTCORE_SECRET_ACCESS_KEY", "agentgov-local-dev-agentcore-secret")
96
+
97
+ DATABRICKS_URL = os.getenv("DATABRICKS_URL", "") # real: https://<workspace>.cloud.databricks.com
98
+ DATABRICKS_UC_SECURABLE = os.getenv("DATABRICKS_UC_SECURABLE", "iops.manufacturing")
99
+ # Privileged admin bearer (PAT or OAuth token scoped to SCIM + Unity Catalog + Apps) the
100
+ # GATEWAY uses for JIT identity: SCIM CREATE USER + UC GRANT/REVOKE. Never a user token.
101
+ # Empty in mock (the mock host doesn't check it).
102
+ DATABRICKS_TOKEN = os.getenv("DATABRICKS_TOKEN", "")
103
+
104
+ SNOWFLAKE_URL = os.getenv("SNOWFLAKE_URL", "") # real: https://<account>.snowflakecomputing.com
105
+ SNOWFLAKE_ROLE_SCOPE = os.getenv("SNOWFLAKE_ROLE_SCOPE", "session:role:GD_CLINICAL_READER")
106
+ # Privileged key-pair for JIT CREATE USER / GRANT / REVOKE (the gateway's admin identity).
107
+ # Store the PRIVATE KEY, not a pre-minted JWT — key-pair JWTs expire in <=1h, so the adapter
108
+ # signs a fresh one per call (app/hosts/snowflake.py::_admin_jwt). Never a user token.
109
+ # Secret: agentgov/prod/snowflake-admin {account,user,private_key,private_key_passphrase}.
110
+ SNOWFLAKE_ADMIN_ACCOUNT = os.getenv("SNOWFLAKE_ADMIN_ACCOUNT", "") # e.g. AULMROR-NLC43968
111
+ SNOWFLAKE_ADMIN_USER = os.getenv("SNOWFLAKE_ADMIN_USER", "") # SECURITYADMIN-capable user
112
+ SNOWFLAKE_ADMIN_ROLE = os.getenv("SNOWFLAKE_ADMIN_ROLE", "SECURITYADMIN")
113
+ SNOWFLAKE_ADMIN_PRIVATE_KEY = os.getenv("SNOWFLAKE_ADMIN_PRIVATE_KEY", "") # PKCS8 PEM
114
+ SNOWFLAKE_ADMIN_PRIVATE_KEY_PASSPHRASE = os.getenv("SNOWFLAKE_ADMIN_PRIVATE_KEY_PASSPHRASE", "")
115
+
116
+ # HostDiscoverySync (Temporal Schedule). Shrunk by the local harness so a scheduled sync
117
+ # is observable during a test run; the demo/prod default is hourly.
118
+ HOST_SYNC_INTERVAL_MINUTES = float(os.getenv("HOST_SYNC_INTERVAL_MINUTES", "60"))
119
+ PULL_HOSTS = [h.strip() for h in os.getenv(
120
+ "AGENTGOV_PULL_HOSTS", "bedrock-agentcore,databricks,snowflake").split(",") if h.strip()]
121
+
122
+ # Browser origins allowed to call the API (the local registry-ui page; the deployed UI is
123
+ # same-origin behind the ALB path prefix, so this stays a dev convenience).
124
+ CORS_ORIGINS = [o.strip() for o in os.getenv(
125
+ "CORS_ORIGINS", "http://localhost:8500,http://127.0.0.1:8500").split(",") if o.strip()]
126
+
127
+
128
+ settings = Settings()
agentgov_core/db.py ADDED
@@ -0,0 +1,491 @@
1
+ """Thin Postgres access for the registry (psycopg 3)."""
2
+ import os
3
+ import psycopg
4
+ from psycopg.rows import dict_row
5
+
6
+ from .config import settings
7
+
8
+ _SCHEMA = os.path.join(os.path.dirname(__file__), "schema.sql")
9
+
10
+
11
+ def connect():
12
+ return psycopg.connect(settings.DB_URL, row_factory=dict_row)
13
+
14
+
15
+ def init_schema():
16
+ with open(_SCHEMA) as f:
17
+ ddl = f.read()
18
+ with connect() as conn, conn.cursor() as cur:
19
+ cur.execute(ddl)
20
+ conn.commit()
21
+
22
+
23
+ _AGENT_COLUMNS = (
24
+ "agent_id", "name", "hosting_mode", "platform", "owning_node", "owner_login",
25
+ "visibility_scope", "entitlement_scope", "classification", "model", "model_provider",
26
+ "version", "version_seq", "gxp_relevant", "data_domains", "dependencies", "runtime_auth",
27
+ "publish_origin", "state", "endpoint", "source_platform", "source_ref", "guardrail_policy",
28
+ "agent_card", "version_transition_policy",
29
+ )
30
+ _AGENT_DEFAULTS = {"endpoint": None, "source_platform": None, "source_ref": None,
31
+ "guardrail_policy": None, "agent_card": "{}",
32
+ "version_transition_policy": "carry_forward"}
33
+
34
+
35
+ def upsert_agent(a: dict):
36
+ """Insert/update one agent card. Used by publish, the host-pull normalizer, and the
37
+ bootstrap seeder. Phase-2 columns default so older callers keep working unchanged."""
38
+ row = {**_AGENT_DEFAULTS, **a}
39
+ cols = ", ".join(_AGENT_COLUMNS)
40
+ vals = ", ".join(f"%({c})s" for c in _AGENT_COLUMNS)
41
+ updates = ", ".join(f"{c}=EXCLUDED.{c}" for c in _AGENT_COLUMNS if c != "agent_id")
42
+ with connect() as conn, conn.cursor() as cur:
43
+ cur.execute(
44
+ f"INSERT INTO agents ({cols}) VALUES ({vals}) "
45
+ f"ON CONFLICT (agent_id) DO UPDATE SET {updates}, updated_at=now()",
46
+ row,
47
+ )
48
+ conn.commit()
49
+
50
+
51
+ def prefilter_agents(user_scopes: list[str]) -> list[dict]:
52
+ """Candidate *active* agents whose visibility/entitlement scope overlaps the user's scope set.
53
+ Only 'active' is discoverable — paused/deprecated/quarantine/retired never surface (§2.1)."""
54
+ with connect() as conn, conn.cursor() as cur:
55
+ cur.execute(
56
+ """
57
+ SELECT * FROM agents
58
+ WHERE state = 'active'
59
+ AND (visibility_scope && %(s)s OR entitlement_scope && %(s)s)
60
+ ORDER BY name
61
+ """,
62
+ {"s": user_scopes},
63
+ )
64
+ return cur.fetchall()
65
+
66
+
67
+ def get_agent(agent_id: str) -> dict | None:
68
+ with connect() as conn, conn.cursor() as cur:
69
+ cur.execute("SELECT * FROM agents WHERE agent_id = %s", (agent_id,))
70
+ return cur.fetchone()
71
+
72
+
73
+ ## ---------------------------------------------------------------- Phase 1: access requests
74
+
75
+ _ACCESS_REQUEST_DEFAULTS = {"agent_id": None, "resource_type": "agent", "mcp_server_id": None,
76
+ "tool_name": None, "renewed_from": None}
77
+
78
+
79
+ def create_access_request(row: dict):
80
+ """`resource_type='agent'` (default) fills `agent_id`; `resource_type='mcp_tool'` (Phase 3
81
+ tool-granularity JIT) fills `mcp_server_id`/`tool_name` instead and leaves `agent_id` NULL.
82
+ `renewed_from` (§6.2) links a renewal back to the grant it supersedes."""
83
+ row = {**_ACCESS_REQUEST_DEFAULTS, **row}
84
+ with connect() as conn, conn.cursor() as cur:
85
+ cur.execute(
86
+ """
87
+ INSERT INTO access_requests
88
+ (request_id, agent_id, resource_type, mcp_server_id, tool_name, requester_login,
89
+ gxp_relevant, renewed_from, state)
90
+ VALUES
91
+ (%(request_id)s, %(agent_id)s, %(resource_type)s, %(mcp_server_id)s, %(tool_name)s,
92
+ %(requester_login)s, %(gxp_relevant)s, %(renewed_from)s, 'pending')
93
+ """,
94
+ row,
95
+ )
96
+ conn.commit()
97
+
98
+
99
+ def set_access_request_workflow(request_id: str, workflow_id: str, run_id: str):
100
+ with connect() as conn, conn.cursor() as cur:
101
+ cur.execute(
102
+ "UPDATE access_requests SET workflow_id=%s, run_id=%s, updated_at=now() WHERE request_id=%s",
103
+ (workflow_id, run_id, request_id),
104
+ )
105
+ conn.commit()
106
+
107
+
108
+ def get_access_request(request_id: str) -> dict | None:
109
+ with connect() as conn, conn.cursor() as cur:
110
+ cur.execute("SELECT * FROM access_requests WHERE request_id = %s", (request_id,))
111
+ return cur.fetchone()
112
+
113
+
114
+ def update_access_request(request_id: str, **fields):
115
+ if not fields:
116
+ return
117
+ cols = ", ".join(f"{k}=%({k})s" for k in fields)
118
+ with connect() as conn, conn.cursor() as cur:
119
+ cur.execute(
120
+ f"UPDATE access_requests SET {cols}, updated_at=now() WHERE request_id=%(request_id)s",
121
+ {**fields, "request_id": request_id},
122
+ )
123
+ conn.commit()
124
+
125
+
126
+ def append_audit(request_id: str, event: str, detail: dict | None = None):
127
+ import json as _json
128
+
129
+ with connect() as conn, conn.cursor() as cur:
130
+ cur.execute(
131
+ "INSERT INTO access_audit (request_id, event, detail) VALUES (%s, %s, %s)",
132
+ (request_id, event, _json.dumps(detail or {})),
133
+ )
134
+ conn.commit()
135
+
136
+
137
+ def list_audit(request_id: str) -> list[dict]:
138
+ with connect() as conn, conn.cursor() as cur:
139
+ cur.execute("SELECT * FROM access_audit WHERE request_id = %s ORDER BY at", (request_id,))
140
+ return cur.fetchall()
141
+
142
+
143
+ def create_ritm(sys_id: str, request_id: str, ritm_type: str, assignment_group: str | None):
144
+ with connect() as conn, conn.cursor() as cur:
145
+ cur.execute(
146
+ """INSERT INTO servicenow_ritms (sys_id, request_id, ritm_type, assignment_group)
147
+ VALUES (%s, %s, %s, %s) ON CONFLICT (sys_id) DO NOTHING""",
148
+ (sys_id, request_id, ritm_type, assignment_group),
149
+ )
150
+ conn.commit()
151
+
152
+
153
+ def set_ritm_state(sys_id: str, state: str):
154
+ with connect() as conn, conn.cursor() as cur:
155
+ cur.execute("UPDATE servicenow_ritms SET state=%s WHERE sys_id=%s", (state, sys_id))
156
+ conn.commit()
157
+
158
+
159
+ def list_ritms_for_request(request_id: str) -> list[dict]:
160
+ with connect() as conn, conn.cursor() as cur:
161
+ cur.execute("SELECT * FROM servicenow_ritms WHERE request_id = %s", (request_id,))
162
+ return cur.fetchall()
163
+
164
+
165
+ def get_request_id_for_ritm(sys_id: str) -> str | None:
166
+ with connect() as conn, conn.cursor() as cur:
167
+ cur.execute("SELECT request_id FROM servicenow_ritms WHERE sys_id = %s", (sys_id,))
168
+ row = cur.fetchone()
169
+ return row["request_id"] if row else None
170
+
171
+
172
+ def list_pending_ritms() -> list[dict]:
173
+ """RITMs still 'requested' locally — candidates for the reconciliation poll."""
174
+ with connect() as conn, conn.cursor() as cur:
175
+ cur.execute("SELECT * FROM servicenow_ritms WHERE state = 'requested'")
176
+ return cur.fetchall()
177
+
178
+
179
+ ## ---------------------------------------------------------------- Phase 2: MCP catalog,
180
+ ## host sync runs, publish claims
181
+
182
+ def set_config(key: str, value):
183
+ import json as _json
184
+
185
+ with connect() as conn, conn.cursor() as cur:
186
+ cur.execute(
187
+ """INSERT INTO registry_config (key, value) VALUES (%s, %s)
188
+ ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()""",
189
+ (key, _json.dumps(value)),
190
+ )
191
+ conn.commit()
192
+
193
+
194
+ def get_config(key: str, default=None):
195
+ with connect() as conn, conn.cursor() as cur:
196
+ cur.execute("SELECT value FROM registry_config WHERE key = %s", (key,))
197
+ row = cur.fetchone()
198
+ return row["value"] if row else default
199
+
200
+
201
+ def upsert_mcp_server(s: dict):
202
+ row = {**s, "restricted_tools": list(s.get("tool_personas", {}).keys())}
203
+ with connect() as conn, conn.cursor() as cur:
204
+ cur.execute(
205
+ """
206
+ INSERT INTO mcp_servers (server_id, name, platform, url, classification,
207
+ data_domains, visibility_scope, entitlement_scope, tools, restricted_tools)
208
+ VALUES (%(server_id)s, %(name)s, %(platform)s, %(url)s, %(classification)s,
209
+ %(data_domains)s, %(visibility_scope)s, %(entitlement_scope)s, %(tools)s,
210
+ %(restricted_tools)s)
211
+ ON CONFLICT (server_id) DO UPDATE SET
212
+ name=EXCLUDED.name, platform=EXCLUDED.platform, url=EXCLUDED.url,
213
+ classification=EXCLUDED.classification, data_domains=EXCLUDED.data_domains,
214
+ visibility_scope=EXCLUDED.visibility_scope,
215
+ entitlement_scope=EXCLUDED.entitlement_scope, tools=EXCLUDED.tools,
216
+ restricted_tools=EXCLUDED.restricted_tools
217
+ """,
218
+ row,
219
+ )
220
+ conn.commit()
221
+
222
+
223
+ def get_mcp_server(server_id: str) -> dict | None:
224
+ with connect() as conn, conn.cursor() as cur:
225
+ cur.execute("SELECT * FROM mcp_servers WHERE server_id = %s", (server_id,))
226
+ return cur.fetchone()
227
+
228
+
229
+ def prefilter_mcp_servers(user_scopes: list[str]) -> list[dict]:
230
+ """Candidate MCP servers whose visibility/entitlement scope overlaps the user's scopes.
231
+ Same prefilter-then-authorize shape as agents; OpenFGA + Cedar still decide."""
232
+ with connect() as conn, conn.cursor() as cur:
233
+ cur.execute(
234
+ """SELECT * FROM mcp_servers
235
+ WHERE visibility_scope && %(s)s OR entitlement_scope && %(s)s
236
+ ORDER BY name""",
237
+ {"s": user_scopes},
238
+ )
239
+ return cur.fetchall()
240
+
241
+
242
+ def record_sync_run(row: dict):
243
+ import json as _json
244
+
245
+ with connect() as conn, conn.cursor() as cur:
246
+ cur.execute(
247
+ """INSERT INTO host_sync_runs (run_id, platform, trigger, discovered, quarantined,
248
+ unchanged, blocked, detail)
249
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
250
+ ON CONFLICT (run_id) DO NOTHING""",
251
+ (row["run_id"], row["platform"], row["trigger"], row["discovered"],
252
+ row["quarantined"], row["unchanged"], row["blocked"], _json.dumps(row.get("detail", {}))),
253
+ )
254
+ conn.commit()
255
+
256
+
257
+ def list_sync_runs(platform: str | None = None, limit: int = 20) -> list[dict]:
258
+ with connect() as conn, conn.cursor() as cur:
259
+ if platform:
260
+ cur.execute("SELECT * FROM host_sync_runs WHERE platform=%s ORDER BY at DESC LIMIT %s",
261
+ (platform, limit))
262
+ else:
263
+ cur.execute("SELECT * FROM host_sync_runs ORDER BY at DESC LIMIT %s", (limit,))
264
+ return cur.fetchall()
265
+
266
+
267
+ def create_publish_claim(row: dict):
268
+ import json as _json
269
+
270
+ with connect() as conn, conn.cursor() as cur:
271
+ cur.execute(
272
+ """INSERT INTO publish_claims (claim_id, agent_id, platform, version, version_seq,
273
+ gxp_relevant, state, envelope)
274
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
275
+ ON CONFLICT (claim_id) DO NOTHING""",
276
+ (row["claim_id"], row["agent_id"], row["platform"], row["version"], row["version_seq"],
277
+ row.get("gxp_relevant", False), row.get("state", "pending"),
278
+ _json.dumps(row.get("envelope", {}))),
279
+ )
280
+ conn.commit()
281
+
282
+
283
+ def update_publish_claim(claim_id: str, **fields):
284
+ import json as _json
285
+
286
+ if not fields:
287
+ return
288
+ if "envelope" in fields and not isinstance(fields["envelope"], str):
289
+ fields["envelope"] = _json.dumps(fields["envelope"])
290
+ cols = ", ".join(f"{k}=%({k})s" for k in fields)
291
+ with connect() as conn, conn.cursor() as cur:
292
+ cur.execute(
293
+ f"UPDATE publish_claims SET {cols}, updated_at=now() WHERE claim_id=%(claim_id)s",
294
+ {**fields, "claim_id": claim_id},
295
+ )
296
+ conn.commit()
297
+
298
+
299
+ def get_publish_claim(claim_id: str) -> dict | None:
300
+ with connect() as conn, conn.cursor() as cur:
301
+ cur.execute("SELECT * FROM publish_claims WHERE claim_id = %s", (claim_id,))
302
+ return cur.fetchone()
303
+
304
+
305
+ def get_open_claim_for_agent(agent_id: str) -> dict | None:
306
+ """The claim still awaiting its owner (or ServiceNow) for this agent, if any."""
307
+ with connect() as conn, conn.cursor() as cur:
308
+ cur.execute(
309
+ """SELECT * FROM publish_claims
310
+ WHERE agent_id=%s AND state IN ('pending','claimed','awaiting_servicenow')
311
+ ORDER BY version_seq DESC, created_at DESC LIMIT 1""",
312
+ (agent_id,),
313
+ )
314
+ return cur.fetchone()
315
+
316
+
317
+ def list_quarantined_agents() -> list[dict]:
318
+ with connect() as conn, conn.cursor() as cur:
319
+ cur.execute("SELECT * FROM agents WHERE state='quarantine' ORDER BY name")
320
+ return cur.fetchall()
321
+
322
+
323
+ def create_ritm_correlated(sys_id: str, correlation_id: str, ritm_type: str,
324
+ assignment_group: str | None, kind: str = "access"):
325
+ """Phase-2 RITM insert that records WHICH workflow family the RITM belongs to."""
326
+ with connect() as conn, conn.cursor() as cur:
327
+ cur.execute(
328
+ """INSERT INTO servicenow_ritms (sys_id, request_id, ritm_type, assignment_group, correlation_kind)
329
+ VALUES (%s, %s, %s, %s, %s) ON CONFLICT (sys_id) DO NOTHING""",
330
+ (sys_id, correlation_id, ritm_type, assignment_group, kind),
331
+ )
332
+ conn.commit()
333
+
334
+
335
+ def get_ritm_row(sys_id: str) -> dict | None:
336
+ with connect() as conn, conn.cursor() as cur:
337
+ cur.execute("SELECT * FROM servicenow_ritms WHERE sys_id = %s", (sys_id,))
338
+ return cur.fetchone()
339
+
340
+
341
+ def set_state(agent_id: str, state: str, watermark_seq: int | None = None):
342
+ """Transition an agent's lifecycle state; optionally raise the deprecation watermark."""
343
+ with connect() as conn, conn.cursor() as cur:
344
+ if watermark_seq is None:
345
+ cur.execute(
346
+ "UPDATE agents SET state=%s, updated_at=now() WHERE agent_id=%s",
347
+ (state, agent_id),
348
+ )
349
+ else:
350
+ cur.execute(
351
+ """UPDATE agents SET state=%s, updated_at=now(),
352
+ deprecated_watermark_seq=GREATEST(COALESCE(deprecated_watermark_seq, 0), %s)
353
+ WHERE agent_id=%s""",
354
+ (state, watermark_seq, agent_id),
355
+ )
356
+ conn.commit()
357
+
358
+
359
+ def schedule_sunset(agent_id: str, sunset_at, reason: str = ""):
360
+ """§6.4: state -> `sunsetting` (deprecated-but-still-serving, §2.1's one new state) +
361
+ record when/why. Deliberately does NOT touch `deprecated_watermark_seq` — that's raised
362
+ only when `SunsetVersion` actually fires, matching how the existing watermark semantics
363
+ ("blocks resurrection of a deprecated version") were never meant to apply to a version
364
+ that's still actively serving during its grace window."""
365
+ with connect() as conn, conn.cursor() as cur:
366
+ cur.execute(
367
+ "UPDATE agents SET state='sunsetting', sunset_at=%s, sunset_reason=%s, updated_at=now() WHERE agent_id=%s",
368
+ (sunset_at, reason, agent_id),
369
+ )
370
+ conn.commit()
371
+
372
+
373
+ def clear_sunset(agent_id: str, to_state: str = "active"):
374
+ """Cancel a scheduled sunset (back to `active`) or clear the columns once `SunsetVersion`
375
+ has finished (the caller then sets the real `deprecated` state separately via `set_state`)."""
376
+ with connect() as conn, conn.cursor() as cur:
377
+ cur.execute(
378
+ "UPDATE agents SET state=%s, sunset_at=NULL, sunset_reason=NULL, updated_at=now() WHERE agent_id=%s",
379
+ (to_state, agent_id),
380
+ )
381
+ conn.commit()
382
+
383
+
384
+ def set_owner(agent_id: str, new_owner_login: str):
385
+ """§6.2 owner handoff — the Postgres side; the caller must ALSO delete the old owner's
386
+ OpenFGA tuple and write the new one (agent_tuples() only ever adds, never removes a stale
387
+ owner — see app/main.py's transfer_owner endpoint)."""
388
+ with connect() as conn, conn.cursor() as cur:
389
+ cur.execute(
390
+ "UPDATE agents SET owner_login=%s, updated_at=now() WHERE agent_id=%s",
391
+ (new_owner_login, agent_id),
392
+ )
393
+ conn.commit()
394
+
395
+
396
+ ## ---------------------------------------------------------------- Phase 6 §6.1: insights
397
+
398
+ def list_agents_all() -> list[dict]:
399
+ """Every agent regardless of lifecycle state — unlike `prefilter_agents`, insights isn't a
400
+ discovery surface: an owner/steward/admin can ask about a paused or deprecated agent's
401
+ historical usage too."""
402
+ with connect() as conn, conn.cursor() as cur:
403
+ cur.execute("SELECT * FROM agents ORDER BY name")
404
+ return cur.fetchall()
405
+
406
+
407
+ def list_agents_by_platform(platform: str) -> list[dict]:
408
+ """§6.3 host management — `platform` is the same literal string on every agent card
409
+ (`agents.platform`) and every `registry_config['hosts']` key, so this is a plain equality
410
+ filter, not a fuzzy host-lookup."""
411
+ with connect() as conn, conn.cursor() as cur:
412
+ cur.execute("SELECT * FROM agents WHERE platform = %s ORDER BY name", (platform,))
413
+ return cur.fetchall()
414
+
415
+
416
+ def list_access_requests(state: str | None = None, agent_id: str | None = None,
417
+ requester_login: str | None = None) -> list[dict]:
418
+ """Backing query for 'current pending requests' / 'active approvals who<->agent<->expiry'
419
+ (§6.1), and 'my grants' / leaver-cascade lookups (§6.2, `requester_login` added there —
420
+ §6.1 only ever queried by agent_id)."""
421
+ clauses, params = ["resource_type = 'agent'"], {}
422
+ if state:
423
+ clauses.append("state = %(state)s")
424
+ params["state"] = state
425
+ if agent_id:
426
+ clauses.append("agent_id = %(agent_id)s")
427
+ params["agent_id"] = agent_id
428
+ if requester_login:
429
+ clauses.append("requester_login = %(requester_login)s")
430
+ params["requester_login"] = requester_login
431
+ with connect() as conn, conn.cursor() as cur:
432
+ cur.execute(
433
+ f"SELECT * FROM access_requests WHERE {' AND '.join(clauses)} ORDER BY created_at DESC",
434
+ params,
435
+ )
436
+ return cur.fetchall()
437
+
438
+
439
+ def upsert_usage_daily(agent_id: str, day, requests: int, in_tokens: int, out_tokens: int):
440
+ with connect() as conn, conn.cursor() as cur:
441
+ cur.execute(
442
+ """
443
+ INSERT INTO usage_daily (agent_id, day, requests, in_tokens, out_tokens, computed_at)
444
+ VALUES (%(agent_id)s, %(day)s, %(requests)s, %(in_tokens)s, %(out_tokens)s, now())
445
+ ON CONFLICT (agent_id, day) DO UPDATE SET
446
+ requests=EXCLUDED.requests, in_tokens=EXCLUDED.in_tokens, out_tokens=EXCLUDED.out_tokens,
447
+ computed_at=now()
448
+ """,
449
+ {"agent_id": agent_id, "day": day, "requests": requests, "in_tokens": in_tokens,
450
+ "out_tokens": out_tokens},
451
+ )
452
+ conn.commit()
453
+
454
+
455
+ def list_usage_daily(agent_id: str | None = None, since_day=None) -> list[dict]:
456
+ clauses, params = ["1=1"], {}
457
+ if agent_id:
458
+ clauses.append("agent_id = %(agent_id)s")
459
+ params["agent_id"] = agent_id
460
+ if since_day:
461
+ clauses.append("day >= %(since_day)s")
462
+ params["since_day"] = since_day
463
+ with connect() as conn, conn.cursor() as cur:
464
+ cur.execute(
465
+ f"SELECT * FROM usage_daily WHERE {' AND '.join(clauses)} ORDER BY day DESC",
466
+ params,
467
+ )
468
+ return cur.fetchall()
469
+
470
+
471
+ ## ---------------------------------------------------------------- Phase 6 §6.3: host management
472
+
473
+ def record_host_admin_action(platform: str, action: str, actor: str, reason: str = "", detail: dict | None = None):
474
+ import json as _json
475
+
476
+ with connect() as conn, conn.cursor() as cur:
477
+ cur.execute(
478
+ "INSERT INTO host_admin_actions (platform, action, actor, reason, detail) VALUES (%s, %s, %s, %s, %s)",
479
+ (platform, action, actor, reason, _json.dumps(detail or {})),
480
+ )
481
+ conn.commit()
482
+
483
+
484
+ def list_host_admin_actions(platform: str | None = None, limit: int = 20) -> list[dict]:
485
+ with connect() as conn, conn.cursor() as cur:
486
+ if platform:
487
+ cur.execute("SELECT * FROM host_admin_actions WHERE platform=%s ORDER BY at DESC LIMIT %s",
488
+ (platform, limit))
489
+ else:
490
+ cur.execute("SELECT * FROM host_admin_actions ORDER BY at DESC LIMIT %s", (limit,))
491
+ return cur.fetchall()