apsimo 1.3.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.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
|
@@ -0,0 +1,2105 @@
|
|
|
1
|
+
"""Durable, transport-neutral authority for Colony action approvals.
|
|
2
|
+
|
|
3
|
+
Colony owns approval *state* but deliberately knows nothing about WhatsApp,
|
|
4
|
+
SMS, an operator deck, or any other decision transport. A host authenticates a
|
|
5
|
+
principal at the API boundary and supplies that server-derived principal to
|
|
6
|
+
this store. Caller prose such as ``approved_by`` is never authority.
|
|
7
|
+
|
|
8
|
+
The store provides two related primitives:
|
|
9
|
+
|
|
10
|
+
* An ``ApprovalRequest`` binds one queued job to an immutable action digest.
|
|
11
|
+
The first valid decision wins; expired or superseded requests cannot be
|
|
12
|
+
revived.
|
|
13
|
+
* A grant is an exact action scope with a configurable duration/use envelope.
|
|
14
|
+
Historical bounded behavior remains the default; an explicit deployment
|
|
15
|
+
setting can select standing duration and/or count without broadening scope.
|
|
16
|
+
|
|
17
|
+
SQLite transactions make decision and grant consumption atomic across Colony
|
|
18
|
+
workers. The database lives in ``COLONY_STATE_DIR`` and is safe to copy with
|
|
19
|
+
the rest of Colony state for rollback.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
import re
|
|
32
|
+
import sqlite3
|
|
33
|
+
from typing import Any, Dict, Iterator, Mapping, Optional
|
|
34
|
+
import uuid
|
|
35
|
+
|
|
36
|
+
from apsimo import get_state_dir
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DB_FILENAME = "approval_authority.db"
|
|
40
|
+
DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60
|
|
41
|
+
DEFAULT_GRANT_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
42
|
+
DEFAULT_GRANT_MAX_USES = 5
|
|
43
|
+
MAX_APPROVAL_REQUEST_TTL_SECONDS = 30 * 24 * 60 * 60
|
|
44
|
+
DEFAULT_GRANT_ENVELOPE_TTL_SECONDS = 30 * 24 * 60 * 60
|
|
45
|
+
DEFAULT_GRANT_ENVELOPE_MAX_USES = 100
|
|
46
|
+
# Backward-compatible names for callers that imported the historical static
|
|
47
|
+
# caps. Runtime grant validation uses GrantEnvelope instead.
|
|
48
|
+
MAX_GRANT_TTL_SECONDS = DEFAULT_GRANT_ENVELOPE_TTL_SECONDS
|
|
49
|
+
MAX_GRANT_USES = DEFAULT_GRANT_ENVELOPE_MAX_USES
|
|
50
|
+
GRANT_MAX_TTL_ENV = "COLONY_GRANT_MAX_TTL_SECONDS"
|
|
51
|
+
GRANT_MAX_USES_ENV = "COLONY_GRANT_MAX_USES"
|
|
52
|
+
GRANT_UNLIMITED_SENTINEL = "unlimited"
|
|
53
|
+
PRESENTATION_SCHEMA = "ColonyApprovalPresentationV1"
|
|
54
|
+
AUTHORIZATION_PROJECTION_SCHEMA = "ColonyApprovalAuthorizationProjectionV1"
|
|
55
|
+
TYPED_APPROVAL_SUBJECT_SCHEMA = "ColonyApprovalSubjectBindingV1"
|
|
56
|
+
|
|
57
|
+
_DECISION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{7,191}$")
|
|
58
|
+
_ACTION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,191}$")
|
|
59
|
+
_SECRET_ASSIGNMENT = re.compile(
|
|
60
|
+
r"(?i)\b(api[_-]?key|authorization|bearer|credential|password|secret|token)"
|
|
61
|
+
r"\s*[:=]\s*(?:bearer\s+)?[^\s,;]+"
|
|
62
|
+
)
|
|
63
|
+
_BEARER_VALUE = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}")
|
|
64
|
+
_PEM_PRIVATE_KEY_BEGIN = re.compile(
|
|
65
|
+
r"-----BEGIN (?P<label>(?:[A-Z0-9]+ )*PRIVATE KEY)-----",
|
|
66
|
+
re.IGNORECASE,
|
|
67
|
+
)
|
|
68
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
69
|
+
_SUBJECT_KIND_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ApprovalAuthorityError(ValueError):
|
|
73
|
+
"""A stable, API-safe approval error."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, code: str, message: str) -> None:
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.code = code
|
|
78
|
+
self.message = message
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class GrantEnvelope:
|
|
83
|
+
"""Effective duration/count limits for newly issued exact-scope grants.
|
|
84
|
+
|
|
85
|
+
``None`` is the normalized form of the explicit ``unlimited`` sentinel.
|
|
86
|
+
It never applies to pending approval requests, which retain their fixed
|
|
87
|
+
request lifetime.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
max_ttl_seconds: Optional[int]
|
|
91
|
+
max_uses: Optional[int]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def standing_dimensions(self) -> tuple[str, ...]:
|
|
95
|
+
dimensions = []
|
|
96
|
+
if self.max_ttl_seconds is None:
|
|
97
|
+
dimensions.append(GRANT_MAX_TTL_ENV)
|
|
98
|
+
if self.max_uses is None:
|
|
99
|
+
dimensions.append(GRANT_MAX_USES_ENV)
|
|
100
|
+
return tuple(dimensions)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _grant_envelope_value(
|
|
104
|
+
name: str,
|
|
105
|
+
*,
|
|
106
|
+
default: int,
|
|
107
|
+
minimum: int,
|
|
108
|
+
) -> Optional[int]:
|
|
109
|
+
raw = os.environ.get(name)
|
|
110
|
+
if raw is None:
|
|
111
|
+
return default
|
|
112
|
+
value = raw.strip()
|
|
113
|
+
if value.lower() == GRANT_UNLIMITED_SENTINEL:
|
|
114
|
+
return None
|
|
115
|
+
if not re.fullmatch(r"[0-9]+", value):
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
f"{name} must be a positive integer or "
|
|
118
|
+
f"{GRANT_UNLIMITED_SENTINEL!r}, got {raw!r}"
|
|
119
|
+
)
|
|
120
|
+
parsed = int(value)
|
|
121
|
+
if parsed < minimum:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"{name} must be at least {minimum} or "
|
|
124
|
+
f"{GRANT_UNLIMITED_SENTINEL!r}, got {raw!r}"
|
|
125
|
+
)
|
|
126
|
+
return parsed
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_grant_envelope() -> GrantEnvelope:
|
|
130
|
+
"""Strictly resolve the grant envelope from the loaded environment."""
|
|
131
|
+
|
|
132
|
+
return GrantEnvelope(
|
|
133
|
+
max_ttl_seconds=_grant_envelope_value(
|
|
134
|
+
GRANT_MAX_TTL_ENV,
|
|
135
|
+
default=DEFAULT_GRANT_ENVELOPE_TTL_SECONDS,
|
|
136
|
+
minimum=60,
|
|
137
|
+
),
|
|
138
|
+
max_uses=_grant_envelope_value(
|
|
139
|
+
GRANT_MAX_USES_ENV,
|
|
140
|
+
default=DEFAULT_GRANT_ENVELOPE_MAX_USES,
|
|
141
|
+
minimum=1,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def grant_envelope_posture(
|
|
147
|
+
envelope: Optional[GrantEnvelope] = None,
|
|
148
|
+
) -> Dict[str, Any]:
|
|
149
|
+
"""Return a typed, audit-safe view of the effective grant envelope."""
|
|
150
|
+
|
|
151
|
+
resolved = envelope or resolve_grant_envelope()
|
|
152
|
+
return {
|
|
153
|
+
"max_ttl_seconds": resolved.max_ttl_seconds,
|
|
154
|
+
"max_ttl_state": (
|
|
155
|
+
"unbounded" if resolved.max_ttl_seconds is None else "bounded"
|
|
156
|
+
),
|
|
157
|
+
"max_uses": resolved.max_uses,
|
|
158
|
+
"max_uses_state": (
|
|
159
|
+
"unbounded" if resolved.max_uses is None else "bounded"
|
|
160
|
+
),
|
|
161
|
+
"standing": bool(resolved.standing_dimensions),
|
|
162
|
+
"sentinel": GRANT_UNLIMITED_SENTINEL,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class ActionBinding:
|
|
168
|
+
"""Server-derived immutable identity and reusable exact scope."""
|
|
169
|
+
|
|
170
|
+
action_digest: str
|
|
171
|
+
scope: Dict[str, Any]
|
|
172
|
+
scope_digest: str
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class ApprovalSubjectBinding:
|
|
177
|
+
"""Server-derived identity for a non-queue approval subject.
|
|
178
|
+
|
|
179
|
+
The authority store remains domain-neutral. Domain services opt in by
|
|
180
|
+
attaching an immutable kind/id/revision/action tuple when the request is
|
|
181
|
+
born; an existing request can never be relabelled as a typed subject.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
kind: str
|
|
185
|
+
subject_id: str
|
|
186
|
+
revision: str
|
|
187
|
+
action: str
|
|
188
|
+
|
|
189
|
+
def payload(self) -> Dict[str, str]:
|
|
190
|
+
return _validated_subject({
|
|
191
|
+
"kind": self.kind,
|
|
192
|
+
"subject_id": self.subject_id,
|
|
193
|
+
"revision": self.revision,
|
|
194
|
+
"action": self.action,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _utcnow() -> datetime:
|
|
199
|
+
return datetime.now(timezone.utc)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _as_utc(value: Optional[datetime]) -> datetime:
|
|
203
|
+
result = value or _utcnow()
|
|
204
|
+
if result.tzinfo is None:
|
|
205
|
+
result = result.replace(tzinfo=timezone.utc)
|
|
206
|
+
return result.astimezone(timezone.utc)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _iso(value: datetime) -> str:
|
|
210
|
+
return _as_utc(value).isoformat()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _canonical(value: Any) -> str:
|
|
214
|
+
"""Canonical JSON used only for digests and bounded public metadata."""
|
|
215
|
+
|
|
216
|
+
def default(item: Any) -> str:
|
|
217
|
+
if isinstance(item, datetime):
|
|
218
|
+
return _iso(item)
|
|
219
|
+
return str(item)
|
|
220
|
+
|
|
221
|
+
return json.dumps(
|
|
222
|
+
value,
|
|
223
|
+
sort_keys=True,
|
|
224
|
+
separators=(",", ":"),
|
|
225
|
+
ensure_ascii=False,
|
|
226
|
+
default=default,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _digest(value: Any) -> str:
|
|
231
|
+
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _validated_subject(value: Mapping[str, Any]) -> Dict[str, str]:
|
|
235
|
+
if not isinstance(value, Mapping) or set(value) != {
|
|
236
|
+
"kind", "subject_id", "revision", "action",
|
|
237
|
+
}:
|
|
238
|
+
raise ApprovalAuthorityError(
|
|
239
|
+
"invalid_subject_binding",
|
|
240
|
+
"approval subject binding fields are invalid",
|
|
241
|
+
)
|
|
242
|
+
kind = str(value.get("kind") or "").strip()
|
|
243
|
+
subject_id = str(value.get("subject_id") or "").strip()
|
|
244
|
+
revision = str(value.get("revision") or "").strip()
|
|
245
|
+
action = str(value.get("action") or "").strip().lower()
|
|
246
|
+
if not _SUBJECT_KIND_RE.fullmatch(kind):
|
|
247
|
+
raise ApprovalAuthorityError(
|
|
248
|
+
"invalid_subject_binding", "approval subject kind is invalid",
|
|
249
|
+
)
|
|
250
|
+
for field, text in (
|
|
251
|
+
("subject_id", subject_id), ("revision", revision),
|
|
252
|
+
):
|
|
253
|
+
if (
|
|
254
|
+
not text or len(text) > 256 or _CONTROL.search(text)
|
|
255
|
+
or not _ACTION_RE.fullmatch(text)
|
|
256
|
+
):
|
|
257
|
+
raise ApprovalAuthorityError(
|
|
258
|
+
"invalid_subject_binding",
|
|
259
|
+
f"approval subject {field} is invalid",
|
|
260
|
+
)
|
|
261
|
+
if not action or not _ACTION_RE.fullmatch(action):
|
|
262
|
+
raise ApprovalAuthorityError(
|
|
263
|
+
"invalid_subject_binding", "approval subject action is invalid",
|
|
264
|
+
)
|
|
265
|
+
return {
|
|
266
|
+
"kind": kind,
|
|
267
|
+
"subject_id": subject_id,
|
|
268
|
+
"revision": revision,
|
|
269
|
+
"action": action,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def approval_subject_digest(
|
|
274
|
+
subject: Mapping[str, Any],
|
|
275
|
+
*,
|
|
276
|
+
action_digest: str,
|
|
277
|
+
scope_digest: str,
|
|
278
|
+
presentation_digest: str,
|
|
279
|
+
) -> str:
|
|
280
|
+
"""Bind a typed subject to the exact action and owner presentation."""
|
|
281
|
+
|
|
282
|
+
return _digest({
|
|
283
|
+
"version": 1,
|
|
284
|
+
"schema": TYPED_APPROVAL_SUBJECT_SCHEMA,
|
|
285
|
+
"subject": _validated_subject(subject),
|
|
286
|
+
"action_digest": str(action_digest),
|
|
287
|
+
"scope_digest": str(scope_digest),
|
|
288
|
+
"presentation_digest": str(presentation_digest),
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _nested_value(payload: Mapping[str, Any], key: str) -> Any:
|
|
293
|
+
if key in payload:
|
|
294
|
+
return payload.get(key)
|
|
295
|
+
context = payload.get("context")
|
|
296
|
+
if isinstance(context, Mapping):
|
|
297
|
+
if key in context:
|
|
298
|
+
return context.get(key)
|
|
299
|
+
upper = key.upper()
|
|
300
|
+
if upper in context:
|
|
301
|
+
return context.get(upper)
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _redact_pem_private_keys(value: str) -> str:
|
|
306
|
+
"""Remove bounded PEM private-key blocks without scanning them greedily."""
|
|
307
|
+
|
|
308
|
+
text = value
|
|
309
|
+
cursor = 0
|
|
310
|
+
for _ in range(32):
|
|
311
|
+
match = _PEM_PRIVATE_KEY_BEGIN.search(text, cursor)
|
|
312
|
+
if match is None:
|
|
313
|
+
return text
|
|
314
|
+
end_pattern = re.compile(
|
|
315
|
+
re.escape(f"-----END {match.group('label')}-----"),
|
|
316
|
+
re.IGNORECASE,
|
|
317
|
+
)
|
|
318
|
+
end = end_pattern.search(text, match.end())
|
|
319
|
+
end_offset = end.end() if end is not None else len(text)
|
|
320
|
+
text = (
|
|
321
|
+
text[:match.start()]
|
|
322
|
+
+ "[REDACTED PRIVATE KEY]"
|
|
323
|
+
+ text[end_offset:]
|
|
324
|
+
)
|
|
325
|
+
cursor = match.start() + len("[REDACTED PRIVATE KEY]")
|
|
326
|
+
if end is None:
|
|
327
|
+
return text
|
|
328
|
+
# An adversarial description with more than 32 PEM blocks is not useful
|
|
329
|
+
# owner-facing content. Preserve its safe prefix and redact the remainder.
|
|
330
|
+
overflow = _PEM_PRIVATE_KEY_BEGIN.search(text, cursor)
|
|
331
|
+
if overflow is not None:
|
|
332
|
+
text = text[:overflow.start()] + "[REDACTED PRIVATE KEY MATERIAL]"
|
|
333
|
+
return text
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _safe_display_text(value: Any, *, maximum: int, fallback: str = "") -> str:
|
|
337
|
+
"""Return bounded single-line owner display text with common secrets removed."""
|
|
338
|
+
|
|
339
|
+
text = _redact_pem_private_keys(str(value or ""))
|
|
340
|
+
text = _CONTROL.sub(" ", text)
|
|
341
|
+
text = _SECRET_ASSIGNMENT.sub(
|
|
342
|
+
lambda match: f"{match.group(1)}=[REDACTED]", text,
|
|
343
|
+
)
|
|
344
|
+
text = _BEARER_VALUE.sub("Bearer [REDACTED]", text)
|
|
345
|
+
text = " ".join(text.split())
|
|
346
|
+
if not text:
|
|
347
|
+
text = fallback
|
|
348
|
+
return text[:maximum]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def approval_binding_digest(
|
|
352
|
+
*,
|
|
353
|
+
job_id: str,
|
|
354
|
+
job_type: str,
|
|
355
|
+
action_digest: str,
|
|
356
|
+
scope_digest: str,
|
|
357
|
+
) -> str:
|
|
358
|
+
return _digest({
|
|
359
|
+
"version": 1,
|
|
360
|
+
"job_id": str(job_id),
|
|
361
|
+
"job_type": str(job_type),
|
|
362
|
+
"action_digest": str(action_digest),
|
|
363
|
+
"scope_digest": str(scope_digest),
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def approval_presentation_digest(presentation: Mapping[str, Any]) -> str:
|
|
368
|
+
return _digest(_validated_presentation(presentation))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _validated_presentation(value: Mapping[str, Any]) -> Dict[str, Any]:
|
|
372
|
+
if not isinstance(value, Mapping):
|
|
373
|
+
raise ApprovalAuthorityError(
|
|
374
|
+
"invalid_presentation", "approval presentation must be an object"
|
|
375
|
+
)
|
|
376
|
+
expected = {
|
|
377
|
+
"schema", "version", "summary", "action_name", "risk", "effect",
|
|
378
|
+
"target", "capabilities", "deadline", "reversibility", "constraints",
|
|
379
|
+
}
|
|
380
|
+
if set(value) != expected:
|
|
381
|
+
raise ApprovalAuthorityError(
|
|
382
|
+
"invalid_presentation", "approval presentation fields are invalid"
|
|
383
|
+
)
|
|
384
|
+
if value.get("schema") != PRESENTATION_SCHEMA or value.get("version") != 1:
|
|
385
|
+
raise ApprovalAuthorityError(
|
|
386
|
+
"invalid_presentation", "approval presentation schema is invalid"
|
|
387
|
+
)
|
|
388
|
+
capabilities = value.get("capabilities")
|
|
389
|
+
constraints = value.get("constraints")
|
|
390
|
+
if (
|
|
391
|
+
not isinstance(capabilities, list)
|
|
392
|
+
or len(capabilities) > 32
|
|
393
|
+
or not all(
|
|
394
|
+
isinstance(item, str) and 0 < len(item) <= 128
|
|
395
|
+
for item in capabilities
|
|
396
|
+
)
|
|
397
|
+
or capabilities != sorted(set(capabilities))
|
|
398
|
+
or not isinstance(constraints, Mapping)
|
|
399
|
+
or len(constraints) > 32
|
|
400
|
+
):
|
|
401
|
+
raise ApprovalAuthorityError(
|
|
402
|
+
"invalid_presentation", "approval presentation bounds are invalid"
|
|
403
|
+
)
|
|
404
|
+
normalized_constraints: Dict[str, Optional[str]] = {}
|
|
405
|
+
for key, digest in constraints.items():
|
|
406
|
+
if (
|
|
407
|
+
not isinstance(key, str)
|
|
408
|
+
or not key
|
|
409
|
+
or len(key) > 128
|
|
410
|
+
or (
|
|
411
|
+
digest is not None
|
|
412
|
+
and (
|
|
413
|
+
not isinstance(digest, str)
|
|
414
|
+
or not re.fullmatch(r"[0-9a-f]{64}", digest)
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
):
|
|
418
|
+
raise ApprovalAuthorityError(
|
|
419
|
+
"invalid_presentation", "approval presentation constraint is invalid"
|
|
420
|
+
)
|
|
421
|
+
normalized_constraints[key] = digest
|
|
422
|
+
result = {
|
|
423
|
+
"schema": PRESENTATION_SCHEMA,
|
|
424
|
+
"version": 1,
|
|
425
|
+
"summary": _safe_display_text(value.get("summary"), maximum=1200),
|
|
426
|
+
"action_name": _safe_display_text(
|
|
427
|
+
value.get("action_name"), maximum=192,
|
|
428
|
+
),
|
|
429
|
+
"risk": _safe_display_text(value.get("risk"), maximum=64),
|
|
430
|
+
"effect": _safe_display_text(value.get("effect"), maximum=32),
|
|
431
|
+
"target": _safe_display_text(value.get("target"), maximum=512),
|
|
432
|
+
"capabilities": list(capabilities),
|
|
433
|
+
"deadline": _safe_display_text(value.get("deadline"), maximum=64),
|
|
434
|
+
"reversibility": _safe_display_text(
|
|
435
|
+
value.get("reversibility"), maximum=256, fallback="unspecified",
|
|
436
|
+
),
|
|
437
|
+
"constraints": dict(sorted(normalized_constraints.items())),
|
|
438
|
+
}
|
|
439
|
+
if not result["summary"] or not result["action_name"] or not result["risk"]:
|
|
440
|
+
raise ApprovalAuthorityError(
|
|
441
|
+
"invalid_presentation", "approval presentation is incomplete"
|
|
442
|
+
)
|
|
443
|
+
if len(_canonical(result).encode("utf-8")) > 16 * 1024:
|
|
444
|
+
raise ApprovalAuthorityError(
|
|
445
|
+
"invalid_presentation", "approval presentation is too large"
|
|
446
|
+
)
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def build_approval_presentation(
|
|
451
|
+
*,
|
|
452
|
+
job_id: str,
|
|
453
|
+
job_type: str,
|
|
454
|
+
payload: Mapping[str, Any],
|
|
455
|
+
deadline: Any = None,
|
|
456
|
+
) -> Dict[str, Any]:
|
|
457
|
+
"""Build the transport-neutral, redacted view an owner may approve.
|
|
458
|
+
|
|
459
|
+
Raw payloads and context references never enter this projection. Exact
|
|
460
|
+
action constraints are represented only by their server-derived digests.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
binding = build_action_binding(
|
|
464
|
+
job_id=job_id,
|
|
465
|
+
job_type=job_type,
|
|
466
|
+
payload=payload,
|
|
467
|
+
)
|
|
468
|
+
action_name = binding.scope["action_name"]
|
|
469
|
+
if payload.get("schema") == "ApprovalRelayCanaryV1":
|
|
470
|
+
from apsimo.task_queue.approval_relay_canary import (
|
|
471
|
+
ACTION_HINT as CANARY_ACTION_HINT,
|
|
472
|
+
job_id_for_digest,
|
|
473
|
+
payload_for_digest,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
digest = payload.get("idempotency_digest")
|
|
477
|
+
exact_canary = bool(
|
|
478
|
+
isinstance(digest, str)
|
|
479
|
+
and action_name == CANARY_ACTION_HINT
|
|
480
|
+
and str(job_id) == job_id_for_digest(digest)
|
|
481
|
+
and dict(payload) == payload_for_digest(digest)
|
|
482
|
+
)
|
|
483
|
+
if not exact_canary:
|
|
484
|
+
raise ApprovalAuthorityError(
|
|
485
|
+
"invalid_action_scope",
|
|
486
|
+
"approval relay canary presentation identity drifted",
|
|
487
|
+
)
|
|
488
|
+
return _validated_presentation({
|
|
489
|
+
"schema": PRESENTATION_SCHEMA,
|
|
490
|
+
"version": 1,
|
|
491
|
+
"summary": (
|
|
492
|
+
"Calibrate the owner approval relay. Either choice records "
|
|
493
|
+
"the decision and permanently cancels this no-effect job."
|
|
494
|
+
),
|
|
495
|
+
"action_name": action_name,
|
|
496
|
+
"risk": "calibration",
|
|
497
|
+
"effect": "none",
|
|
498
|
+
"target": "internal approval relay",
|
|
499
|
+
"capabilities": [],
|
|
500
|
+
"deadline": _safe_display_text(
|
|
501
|
+
deadline, maximum=64, fallback="unspecified",
|
|
502
|
+
),
|
|
503
|
+
"reversibility": "no execution; terminal cancellation only",
|
|
504
|
+
"constraints": binding.scope.get("constraints") or {},
|
|
505
|
+
})
|
|
506
|
+
risk = str(binding.scope.get("risk") or "unknown")
|
|
507
|
+
effect = "unknown"
|
|
508
|
+
if risk in {"mutation", "mutating", "destructive"}:
|
|
509
|
+
effect = "mutation"
|
|
510
|
+
elif risk in {"disclosure", "outbound"}:
|
|
511
|
+
effect = "disclosure"
|
|
512
|
+
elif risk in {"read_only", "internal"}:
|
|
513
|
+
effect = "none"
|
|
514
|
+
|
|
515
|
+
summary = (
|
|
516
|
+
payload.get("objective")
|
|
517
|
+
or payload.get("description")
|
|
518
|
+
or payload.get("request")
|
|
519
|
+
or action_name
|
|
520
|
+
)
|
|
521
|
+
target = payload.get("recipient_scope")
|
|
522
|
+
if target in (None, ""):
|
|
523
|
+
try:
|
|
524
|
+
from apsimo.initiatives.action_registry import get_action
|
|
525
|
+
|
|
526
|
+
spec = get_action(action_name)
|
|
527
|
+
target = _nested_value(payload, spec.target_param) if spec and spec.target_param else None
|
|
528
|
+
except Exception:
|
|
529
|
+
target = None
|
|
530
|
+
if target in (None, ""):
|
|
531
|
+
for name in ("recipient", "target", "to"):
|
|
532
|
+
target = _nested_value(payload, name)
|
|
533
|
+
if target not in (None, ""):
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
raw_capabilities = payload.get("capability_allowlist") or ()
|
|
537
|
+
capabilities = sorted({
|
|
538
|
+
_safe_display_text(item, maximum=128)
|
|
539
|
+
for item in raw_capabilities
|
|
540
|
+
if _safe_display_text(item, maximum=128)
|
|
541
|
+
})[:32]
|
|
542
|
+
reversibility = (
|
|
543
|
+
payload.get("reversibility")
|
|
544
|
+
or ("reversible" if payload.get("reversible") is True else None)
|
|
545
|
+
or ("irreversible" if payload.get("reversible") is False else None)
|
|
546
|
+
or "unspecified"
|
|
547
|
+
)
|
|
548
|
+
return _validated_presentation({
|
|
549
|
+
"schema": PRESENTATION_SCHEMA,
|
|
550
|
+
"version": 1,
|
|
551
|
+
"summary": _safe_display_text(summary, maximum=1200, fallback=action_name),
|
|
552
|
+
"action_name": action_name,
|
|
553
|
+
"risk": risk,
|
|
554
|
+
"effect": effect,
|
|
555
|
+
"target": _safe_display_text(target, maximum=512, fallback="unspecified"),
|
|
556
|
+
"capabilities": capabilities,
|
|
557
|
+
"deadline": _safe_display_text(
|
|
558
|
+
deadline or payload.get("deadline"), maximum=64, fallback="unspecified",
|
|
559
|
+
),
|
|
560
|
+
"reversibility": reversibility,
|
|
561
|
+
"constraints": binding.scope.get("constraints") or {},
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _request_ttl_for_deadline(
|
|
566
|
+
deadline: Any,
|
|
567
|
+
*,
|
|
568
|
+
now: Optional[datetime] = None,
|
|
569
|
+
) -> int:
|
|
570
|
+
"""Return a request lifetime that never outlives the queue deadline."""
|
|
571
|
+
|
|
572
|
+
observed = _as_utc(now)
|
|
573
|
+
if deadline in (None, ""):
|
|
574
|
+
return DEFAULT_REQUEST_TTL_SECONDS
|
|
575
|
+
if isinstance(deadline, datetime):
|
|
576
|
+
parsed = _as_utc(deadline)
|
|
577
|
+
else:
|
|
578
|
+
try:
|
|
579
|
+
parsed = datetime.fromisoformat(str(deadline).replace("Z", "+00:00"))
|
|
580
|
+
except (TypeError, ValueError):
|
|
581
|
+
return DEFAULT_REQUEST_TTL_SECONDS
|
|
582
|
+
parsed = _as_utc(parsed)
|
|
583
|
+
remaining = int((parsed - observed).total_seconds())
|
|
584
|
+
if remaining < 60:
|
|
585
|
+
return 0
|
|
586
|
+
return min(DEFAULT_REQUEST_TTL_SECONDS, remaining)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def canonical_approval_timeout_seconds() -> int:
|
|
590
|
+
"""One timeout used by request creation and blocked-job governance."""
|
|
591
|
+
|
|
592
|
+
raw = os.environ.get("COLONY_APPROVAL_TIMEOUT_HOURS")
|
|
593
|
+
if raw in (None, ""):
|
|
594
|
+
return DEFAULT_REQUEST_TTL_SECONDS
|
|
595
|
+
try:
|
|
596
|
+
seconds = int(float(raw) * 60 * 60)
|
|
597
|
+
except (TypeError, ValueError):
|
|
598
|
+
return DEFAULT_REQUEST_TTL_SECONDS
|
|
599
|
+
return max(60, min(seconds, MAX_APPROVAL_REQUEST_TTL_SECONDS))
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def prepare_action_approval(
|
|
603
|
+
store: "ApprovalAuthorityStore",
|
|
604
|
+
*,
|
|
605
|
+
job_id: str,
|
|
606
|
+
job_type: str,
|
|
607
|
+
payload: Mapping[str, Any],
|
|
608
|
+
deadline: Any = None,
|
|
609
|
+
approval_started_at: Optional[datetime] = None,
|
|
610
|
+
now: Optional[datetime] = None,
|
|
611
|
+
) -> Dict[str, Any]:
|
|
612
|
+
"""Resolve one server-owned effect gate without any transport coupling.
|
|
613
|
+
|
|
614
|
+
The caller must keep the queue row unclaimable until it durably applies the
|
|
615
|
+
returned state/tags. Repeating this function reuses the exact request or
|
|
616
|
+
grant-use record, which closes restart and partial-transition gaps.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
binding = build_action_binding(
|
|
620
|
+
job_id=job_id,
|
|
621
|
+
job_type=job_type,
|
|
622
|
+
payload=payload,
|
|
623
|
+
)
|
|
624
|
+
presentation = build_approval_presentation(
|
|
625
|
+
job_id=job_id,
|
|
626
|
+
job_type=job_type,
|
|
627
|
+
payload=payload,
|
|
628
|
+
deadline=deadline,
|
|
629
|
+
)
|
|
630
|
+
presentation_digest = _digest(presentation)
|
|
631
|
+
common = {
|
|
632
|
+
"action_digest": binding.action_digest,
|
|
633
|
+
"approval_scope_digest": binding.scope_digest,
|
|
634
|
+
"approval_presentation_digest": presentation_digest,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
def request_result(request: Mapping[str, Any]) -> Dict[str, Any]:
|
|
638
|
+
request_tags = {
|
|
639
|
+
**common,
|
|
640
|
+
"approval_request_id": request["request_id"],
|
|
641
|
+
"approval_binding_digest": request["binding_digest"],
|
|
642
|
+
"approval_request_digest": request["request_digest"],
|
|
643
|
+
"approval_expires_at": request["expires_at"],
|
|
644
|
+
}
|
|
645
|
+
if request["status"] == "approved" and request.get("decision") == "approve":
|
|
646
|
+
state = "authorized_direct"
|
|
647
|
+
request_tags.update({
|
|
648
|
+
"approval_provenance": "server_direct_decision",
|
|
649
|
+
"approval_decision_id": request["decision_id"],
|
|
650
|
+
"approved_by": request["decided_by"],
|
|
651
|
+
"approved_at": request["decided_at"],
|
|
652
|
+
})
|
|
653
|
+
elif request["status"] == "rejected":
|
|
654
|
+
state = "rejected"
|
|
655
|
+
request_tags.update({
|
|
656
|
+
"approval_provenance": "server_direct_decision",
|
|
657
|
+
"approval_decision_id": request["decision_id"],
|
|
658
|
+
"rejected_by": request["decided_by"],
|
|
659
|
+
"rejected_at": request["decided_at"],
|
|
660
|
+
})
|
|
661
|
+
elif request["status"] in {"expired", "superseded"}:
|
|
662
|
+
state = request["status"]
|
|
663
|
+
else:
|
|
664
|
+
state = "pending"
|
|
665
|
+
return {
|
|
666
|
+
"state": state,
|
|
667
|
+
"binding": binding,
|
|
668
|
+
"presentation": presentation,
|
|
669
|
+
"request": dict(request),
|
|
670
|
+
"grant_use": None,
|
|
671
|
+
"tags": request_tags,
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
observed = _as_utc(now)
|
|
675
|
+
effective_deadline = deadline
|
|
676
|
+
if approval_started_at is not None:
|
|
677
|
+
approval_deadline = _as_utc(approval_started_at) + timedelta(
|
|
678
|
+
seconds=canonical_approval_timeout_seconds()
|
|
679
|
+
)
|
|
680
|
+
if effective_deadline in (None, ""):
|
|
681
|
+
effective_deadline = approval_deadline
|
|
682
|
+
else:
|
|
683
|
+
try:
|
|
684
|
+
parsed_deadline = (
|
|
685
|
+
_as_utc(effective_deadline)
|
|
686
|
+
if isinstance(effective_deadline, datetime)
|
|
687
|
+
else _as_utc(datetime.fromisoformat(
|
|
688
|
+
str(effective_deadline).replace("Z", "+00:00")
|
|
689
|
+
))
|
|
690
|
+
)
|
|
691
|
+
except (TypeError, ValueError):
|
|
692
|
+
parsed_deadline = approval_deadline
|
|
693
|
+
effective_deadline = min(parsed_deadline, approval_deadline)
|
|
694
|
+
ttl_seconds = _request_ttl_for_deadline(effective_deadline, now=observed)
|
|
695
|
+
resolved = store.resolve_action_gate(
|
|
696
|
+
job_id=job_id,
|
|
697
|
+
binding=binding,
|
|
698
|
+
operation_id=job_id,
|
|
699
|
+
ttl_seconds=ttl_seconds,
|
|
700
|
+
presentation=presentation,
|
|
701
|
+
now=observed,
|
|
702
|
+
)
|
|
703
|
+
if resolved["kind"] == "request":
|
|
704
|
+
# Reconciliation preserves the original exact request and expiry.
|
|
705
|
+
# A durable request (including a rejection or projected expiry) always
|
|
706
|
+
# wins over a reusable grant for the same operation.
|
|
707
|
+
return request_result(resolved["request"])
|
|
708
|
+
if resolved["kind"] == "deadline_expired":
|
|
709
|
+
return {
|
|
710
|
+
"state": "expired",
|
|
711
|
+
"binding": binding,
|
|
712
|
+
"presentation": presentation,
|
|
713
|
+
"request": None,
|
|
714
|
+
"grant_use": None,
|
|
715
|
+
"tags": {
|
|
716
|
+
**common,
|
|
717
|
+
"approval_provenance": "server_deadline_expired",
|
|
718
|
+
},
|
|
719
|
+
}
|
|
720
|
+
if resolved["kind"] != "grant":
|
|
721
|
+
raise ApprovalAuthorityError(
|
|
722
|
+
"approval_resolution_invalid",
|
|
723
|
+
"approval gate returned an invalid authority state",
|
|
724
|
+
)
|
|
725
|
+
use = resolved["grant_use"]
|
|
726
|
+
if use is None:
|
|
727
|
+
raise ApprovalAuthorityError(
|
|
728
|
+
"grant_provenance_missing",
|
|
729
|
+
"bounded grant consumption has no durable use record",
|
|
730
|
+
)
|
|
731
|
+
ttl_unbounded = bool(use.get("grant_ttl_unbounded"))
|
|
732
|
+
uses_unbounded = bool(use.get("grant_uses_unbounded"))
|
|
733
|
+
return {
|
|
734
|
+
"state": "authorized_grant",
|
|
735
|
+
"binding": binding,
|
|
736
|
+
"presentation": presentation,
|
|
737
|
+
"request": None,
|
|
738
|
+
"grant_use": use,
|
|
739
|
+
"tags": {
|
|
740
|
+
**common,
|
|
741
|
+
"auto_approved_by_policy": "bounded_grant",
|
|
742
|
+
"approval_provenance": "server_bounded_grant",
|
|
743
|
+
"bounded_grant_id": use["grant_id"],
|
|
744
|
+
"approval_source_request_id": use["source_request_id"],
|
|
745
|
+
"approval_decision_id": use["decision_id"],
|
|
746
|
+
"approved_by": use["granted_by"],
|
|
747
|
+
"approved_at": use["consumed_at"],
|
|
748
|
+
"bounded_grant_expires_at": (
|
|
749
|
+
GRANT_UNLIMITED_SENTINEL
|
|
750
|
+
if ttl_unbounded else use["grant_expires_at"]
|
|
751
|
+
),
|
|
752
|
+
"bounded_grant_ttl_state": (
|
|
753
|
+
"unbounded" if ttl_unbounded else "bounded"
|
|
754
|
+
),
|
|
755
|
+
"bounded_grant_uses_state": (
|
|
756
|
+
"unbounded" if uses_unbounded else "bounded"
|
|
757
|
+
),
|
|
758
|
+
},
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def build_action_binding(
|
|
763
|
+
*,
|
|
764
|
+
job_id: str,
|
|
765
|
+
job_type: str,
|
|
766
|
+
payload: Mapping[str, Any],
|
|
767
|
+
) -> ActionBinding:
|
|
768
|
+
"""Derive an immutable job digest and an exact reusable grant scope.
|
|
769
|
+
|
|
770
|
+
Scope is based solely on the registered action contract and hashes of its
|
|
771
|
+
required values. Raw recipient, message, path, and repository values are
|
|
772
|
+
therefore not copied into the authority database. Missing constraints are
|
|
773
|
+
represented explicitly, so a later request cannot broaden an approval by
|
|
774
|
+
dropping a field.
|
|
775
|
+
"""
|
|
776
|
+
|
|
777
|
+
action_name = str(payload.get("action_hint") or "").strip()
|
|
778
|
+
if not action_name or not _ACTION_RE.fullmatch(action_name):
|
|
779
|
+
raise ApprovalAuthorityError(
|
|
780
|
+
"invalid_action_scope", "job has no valid registered action name"
|
|
781
|
+
)
|
|
782
|
+
normalized_job_type = str(job_type or "").strip()
|
|
783
|
+
if not normalized_job_type:
|
|
784
|
+
raise ApprovalAuthorityError("invalid_action_scope", "job_type is required")
|
|
785
|
+
|
|
786
|
+
required: list[str] = []
|
|
787
|
+
risk = str(payload.get("risk") or payload.get("risk_class") or "unknown").strip()
|
|
788
|
+
try:
|
|
789
|
+
from apsimo.initiatives.action_registry import get_action
|
|
790
|
+
|
|
791
|
+
spec = get_action(action_name)
|
|
792
|
+
if spec is not None:
|
|
793
|
+
required = list(spec.required_params)
|
|
794
|
+
if spec.target_param and spec.target_param not in required:
|
|
795
|
+
required.append(spec.target_param)
|
|
796
|
+
risk = spec.risk.value
|
|
797
|
+
except Exception:
|
|
798
|
+
# WorkOrder and extension actions can be registered by the host rather
|
|
799
|
+
# than Colony's built-in registry. Their exact action/risk/type tuple is
|
|
800
|
+
# still bounded; the complete payload remains bound by action_digest.
|
|
801
|
+
pass
|
|
802
|
+
|
|
803
|
+
if payload.get("schema") == "WorkOrderV1" and payload.get("version") == 1:
|
|
804
|
+
# WorkOrder actions are host extension actions rather than built-in
|
|
805
|
+
# registry entries. Their reusable scope must still bind the project,
|
|
806
|
+
# recipient, source and exact capability ceiling. Step/job identity is
|
|
807
|
+
# intentionally excluded so a bounded multi-use grant can authorize a
|
|
808
|
+
# later step only inside that same scope.
|
|
809
|
+
required = [
|
|
810
|
+
"source", "project_id", "recipient_scope", "capability_allowlist",
|
|
811
|
+
]
|
|
812
|
+
risk = str(payload.get("risk_class") or risk or "unknown").strip()
|
|
813
|
+
|
|
814
|
+
constraints: Dict[str, Optional[str]] = {}
|
|
815
|
+
for name in sorted(set(required)):
|
|
816
|
+
raw = _nested_value(payload, name)
|
|
817
|
+
constraints[name] = _digest(raw) if raw is not None else None
|
|
818
|
+
|
|
819
|
+
scope = {
|
|
820
|
+
"version": 1,
|
|
821
|
+
"job_type": normalized_job_type,
|
|
822
|
+
"action_name": action_name,
|
|
823
|
+
"risk": risk or "unknown",
|
|
824
|
+
"constraints": constraints,
|
|
825
|
+
}
|
|
826
|
+
snapshot = {
|
|
827
|
+
"version": 1,
|
|
828
|
+
"job_id": str(job_id),
|
|
829
|
+
"job_type": normalized_job_type,
|
|
830
|
+
"payload": dict(payload),
|
|
831
|
+
}
|
|
832
|
+
return ActionBinding(
|
|
833
|
+
action_digest=_digest(snapshot),
|
|
834
|
+
scope=scope,
|
|
835
|
+
scope_digest=_digest(scope),
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def legacy_action_binding(
|
|
840
|
+
action_name: str,
|
|
841
|
+
*,
|
|
842
|
+
operation_id: str,
|
|
843
|
+
job_type: str = "legacy_action",
|
|
844
|
+
risk: str = "mutating",
|
|
845
|
+
) -> ActionBinding:
|
|
846
|
+
"""Build a bounded scope for older non-queue action gates."""
|
|
847
|
+
|
|
848
|
+
payload = {"action_hint": action_name, "risk": risk}
|
|
849
|
+
return build_action_binding(
|
|
850
|
+
job_id=operation_id,
|
|
851
|
+
job_type=job_type,
|
|
852
|
+
payload=payload,
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
class ApprovalAuthorityStore:
|
|
857
|
+
"""Transactional approval and bounded-grant ledger."""
|
|
858
|
+
|
|
859
|
+
def __init__(
|
|
860
|
+
self,
|
|
861
|
+
db_path: str | os.PathLike[str] | None = None,
|
|
862
|
+
*,
|
|
863
|
+
grant_envelope: Optional[GrantEnvelope] = None,
|
|
864
|
+
) -> None:
|
|
865
|
+
self.grant_envelope = grant_envelope or resolve_grant_envelope()
|
|
866
|
+
self.path = Path(db_path) if db_path is not None else get_state_dir() / DB_FILENAME
|
|
867
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
868
|
+
if not self.path.exists():
|
|
869
|
+
self.path.touch(mode=0o600)
|
|
870
|
+
else:
|
|
871
|
+
os.chmod(self.path, 0o600)
|
|
872
|
+
self._initialize()
|
|
873
|
+
|
|
874
|
+
@contextmanager
|
|
875
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
876
|
+
conn = sqlite3.connect(str(self.path), timeout=30.0)
|
|
877
|
+
try:
|
|
878
|
+
conn.row_factory = sqlite3.Row
|
|
879
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
880
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
881
|
+
# sqlite3.Connection.__exit__ commits or rolls back but does not
|
|
882
|
+
# close. Preserve its transaction semantics and own the resource
|
|
883
|
+
# lifetime explicitly so repeated approval projections cannot
|
|
884
|
+
# exhaust the process descriptor limit.
|
|
885
|
+
with conn:
|
|
886
|
+
yield conn
|
|
887
|
+
finally:
|
|
888
|
+
conn.close()
|
|
889
|
+
|
|
890
|
+
def _initialize(self) -> None:
|
|
891
|
+
with self._connect() as conn:
|
|
892
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
893
|
+
conn.executescript(
|
|
894
|
+
"""
|
|
895
|
+
CREATE TABLE IF NOT EXISTS approval_requests (
|
|
896
|
+
request_id TEXT PRIMARY KEY,
|
|
897
|
+
job_id TEXT NOT NULL,
|
|
898
|
+
action_digest TEXT NOT NULL,
|
|
899
|
+
scope_json TEXT NOT NULL,
|
|
900
|
+
scope_digest TEXT NOT NULL,
|
|
901
|
+
status TEXT NOT NULL,
|
|
902
|
+
created_at TEXT NOT NULL,
|
|
903
|
+
expires_at TEXT NOT NULL,
|
|
904
|
+
superseded_by TEXT,
|
|
905
|
+
decision TEXT,
|
|
906
|
+
decision_id TEXT UNIQUE,
|
|
907
|
+
decided_at TEXT,
|
|
908
|
+
decided_by TEXT,
|
|
909
|
+
authority_evidence TEXT,
|
|
910
|
+
grant_id TEXT,
|
|
911
|
+
presentation_json TEXT,
|
|
912
|
+
presentation_digest TEXT,
|
|
913
|
+
subject_kind TEXT,
|
|
914
|
+
subject_id TEXT,
|
|
915
|
+
subject_revision TEXT,
|
|
916
|
+
subject_action TEXT,
|
|
917
|
+
subject_digest TEXT
|
|
918
|
+
);
|
|
919
|
+
CREATE INDEX IF NOT EXISTS approval_requests_job_idx
|
|
920
|
+
ON approval_requests(job_id, created_at DESC);
|
|
921
|
+
CREATE INDEX IF NOT EXISTS approval_requests_status_idx
|
|
922
|
+
ON approval_requests(status, expires_at);
|
|
923
|
+
|
|
924
|
+
CREATE TABLE IF NOT EXISTS bounded_grants (
|
|
925
|
+
grant_id TEXT PRIMARY KEY,
|
|
926
|
+
source_request_id TEXT NOT NULL,
|
|
927
|
+
scope_json TEXT NOT NULL,
|
|
928
|
+
scope_digest TEXT NOT NULL,
|
|
929
|
+
status TEXT NOT NULL,
|
|
930
|
+
created_at TEXT NOT NULL,
|
|
931
|
+
expires_at TEXT NOT NULL,
|
|
932
|
+
ttl_unbounded INTEGER NOT NULL DEFAULT 0
|
|
933
|
+
CHECK (ttl_unbounded IN (0, 1)),
|
|
934
|
+
max_uses INTEGER NOT NULL,
|
|
935
|
+
uses_unbounded INTEGER NOT NULL DEFAULT 0
|
|
936
|
+
CHECK (uses_unbounded IN (0, 1)),
|
|
937
|
+
uses INTEGER NOT NULL DEFAULT 0,
|
|
938
|
+
granted_by TEXT NOT NULL,
|
|
939
|
+
decision_id TEXT NOT NULL,
|
|
940
|
+
revoked_at TEXT,
|
|
941
|
+
FOREIGN KEY(source_request_id)
|
|
942
|
+
REFERENCES approval_requests(request_id)
|
|
943
|
+
);
|
|
944
|
+
CREATE INDEX IF NOT EXISTS bounded_grants_scope_idx
|
|
945
|
+
ON bounded_grants(scope_digest, status, expires_at);
|
|
946
|
+
|
|
947
|
+
CREATE TABLE IF NOT EXISTS bounded_grant_uses (
|
|
948
|
+
action_digest TEXT PRIMARY KEY,
|
|
949
|
+
grant_id TEXT NOT NULL,
|
|
950
|
+
operation_id TEXT NOT NULL,
|
|
951
|
+
consumed_at TEXT NOT NULL,
|
|
952
|
+
FOREIGN KEY(grant_id) REFERENCES bounded_grants(grant_id)
|
|
953
|
+
);
|
|
954
|
+
"""
|
|
955
|
+
)
|
|
956
|
+
columns = {
|
|
957
|
+
row["name"]
|
|
958
|
+
for row in conn.execute(
|
|
959
|
+
"PRAGMA table_info(approval_requests)"
|
|
960
|
+
).fetchall()
|
|
961
|
+
}
|
|
962
|
+
if "presentation_json" not in columns:
|
|
963
|
+
conn.execute(
|
|
964
|
+
"ALTER TABLE approval_requests ADD COLUMN presentation_json TEXT"
|
|
965
|
+
)
|
|
966
|
+
if "presentation_digest" not in columns:
|
|
967
|
+
conn.execute(
|
|
968
|
+
"ALTER TABLE approval_requests ADD COLUMN presentation_digest TEXT"
|
|
969
|
+
)
|
|
970
|
+
subject_columns = {
|
|
971
|
+
"subject_kind": "TEXT",
|
|
972
|
+
"subject_id": "TEXT",
|
|
973
|
+
"subject_revision": "TEXT",
|
|
974
|
+
"subject_action": "TEXT",
|
|
975
|
+
"subject_digest": "TEXT",
|
|
976
|
+
}
|
|
977
|
+
for name, column_type in subject_columns.items():
|
|
978
|
+
if name not in columns:
|
|
979
|
+
conn.execute(
|
|
980
|
+
f"ALTER TABLE approval_requests ADD COLUMN {name} "
|
|
981
|
+
f"{column_type}"
|
|
982
|
+
)
|
|
983
|
+
grant_columns = {
|
|
984
|
+
row["name"]
|
|
985
|
+
for row in conn.execute(
|
|
986
|
+
"PRAGMA table_info(bounded_grants)"
|
|
987
|
+
).fetchall()
|
|
988
|
+
}
|
|
989
|
+
if "ttl_unbounded" not in grant_columns:
|
|
990
|
+
conn.execute(
|
|
991
|
+
"ALTER TABLE bounded_grants ADD COLUMN ttl_unbounded "
|
|
992
|
+
"INTEGER NOT NULL DEFAULT 0 "
|
|
993
|
+
"CHECK (ttl_unbounded IN (0, 1))"
|
|
994
|
+
)
|
|
995
|
+
if "uses_unbounded" not in grant_columns:
|
|
996
|
+
conn.execute(
|
|
997
|
+
"ALTER TABLE bounded_grants ADD COLUMN uses_unbounded "
|
|
998
|
+
"INTEGER NOT NULL DEFAULT 0 "
|
|
999
|
+
"CHECK (uses_unbounded IN (0, 1))"
|
|
1000
|
+
)
|
|
1001
|
+
conn.execute(
|
|
1002
|
+
"CREATE INDEX IF NOT EXISTS approval_requests_subject_idx "
|
|
1003
|
+
"ON approval_requests(subject_kind, status, created_at DESC)"
|
|
1004
|
+
)
|
|
1005
|
+
os.chmod(self.path, 0o600)
|
|
1006
|
+
|
|
1007
|
+
@staticmethod
|
|
1008
|
+
def _request_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
|
1009
|
+
result = dict(row)
|
|
1010
|
+
result["scope"] = json.loads(result.pop("scope_json"))
|
|
1011
|
+
raw_presentation = result.pop("presentation_json", None)
|
|
1012
|
+
if raw_presentation:
|
|
1013
|
+
try:
|
|
1014
|
+
presentation = _validated_presentation(json.loads(raw_presentation))
|
|
1015
|
+
except (ApprovalAuthorityError, TypeError, ValueError) as exc:
|
|
1016
|
+
raise ApprovalAuthorityError(
|
|
1017
|
+
"presentation_integrity_failed",
|
|
1018
|
+
"stored approval presentation is invalid",
|
|
1019
|
+
) from exc
|
|
1020
|
+
else:
|
|
1021
|
+
presentation = None
|
|
1022
|
+
if presentation is None:
|
|
1023
|
+
presentation = _validated_presentation({
|
|
1024
|
+
"schema": PRESENTATION_SCHEMA,
|
|
1025
|
+
"version": 1,
|
|
1026
|
+
"summary": result["scope"].get("action_name") or "legacy action",
|
|
1027
|
+
"action_name": result["scope"].get("action_name") or "legacy_action",
|
|
1028
|
+
"risk": result["scope"].get("risk") or "unknown",
|
|
1029
|
+
"effect": "unknown",
|
|
1030
|
+
"target": "unspecified",
|
|
1031
|
+
"capabilities": [],
|
|
1032
|
+
"deadline": "unspecified",
|
|
1033
|
+
"reversibility": "unspecified",
|
|
1034
|
+
"constraints": result["scope"].get("constraints") or {},
|
|
1035
|
+
})
|
|
1036
|
+
computed_presentation_digest = _digest(presentation)
|
|
1037
|
+
stored_presentation_digest = str(result.get("presentation_digest") or "")
|
|
1038
|
+
if (
|
|
1039
|
+
stored_presentation_digest
|
|
1040
|
+
and stored_presentation_digest != computed_presentation_digest
|
|
1041
|
+
):
|
|
1042
|
+
raise ApprovalAuthorityError(
|
|
1043
|
+
"presentation_integrity_failed",
|
|
1044
|
+
"stored approval presentation does not match its digest",
|
|
1045
|
+
)
|
|
1046
|
+
result["presentation"] = presentation
|
|
1047
|
+
result["presentation_digest"] = computed_presentation_digest
|
|
1048
|
+
raw_subject = {
|
|
1049
|
+
"kind": result.pop("subject_kind", None),
|
|
1050
|
+
"subject_id": result.pop("subject_id", None),
|
|
1051
|
+
"revision": result.pop("subject_revision", None),
|
|
1052
|
+
"action": result.pop("subject_action", None),
|
|
1053
|
+
}
|
|
1054
|
+
stored_subject_digest = str(result.pop("subject_digest", None) or "")
|
|
1055
|
+
populated_subject_fields = [
|
|
1056
|
+
value not in (None, "") for value in raw_subject.values()
|
|
1057
|
+
]
|
|
1058
|
+
if any(populated_subject_fields) and not all(populated_subject_fields):
|
|
1059
|
+
raise ApprovalAuthorityError(
|
|
1060
|
+
"subject_integrity_failed",
|
|
1061
|
+
"stored approval subject binding is incomplete",
|
|
1062
|
+
)
|
|
1063
|
+
subject = None
|
|
1064
|
+
computed_subject_digest = None
|
|
1065
|
+
if all(populated_subject_fields):
|
|
1066
|
+
subject = _validated_subject(raw_subject)
|
|
1067
|
+
computed_subject_digest = approval_subject_digest(
|
|
1068
|
+
subject,
|
|
1069
|
+
action_digest=result["action_digest"],
|
|
1070
|
+
scope_digest=result["scope_digest"],
|
|
1071
|
+
presentation_digest=computed_presentation_digest,
|
|
1072
|
+
)
|
|
1073
|
+
if stored_subject_digest != computed_subject_digest:
|
|
1074
|
+
raise ApprovalAuthorityError(
|
|
1075
|
+
"subject_integrity_failed",
|
|
1076
|
+
"stored approval subject binding does not match its digest",
|
|
1077
|
+
)
|
|
1078
|
+
elif stored_subject_digest:
|
|
1079
|
+
raise ApprovalAuthorityError(
|
|
1080
|
+
"subject_integrity_failed",
|
|
1081
|
+
"stored approval subject digest has no subject binding",
|
|
1082
|
+
)
|
|
1083
|
+
result["subject"] = subject
|
|
1084
|
+
result["subject_digest"] = computed_subject_digest
|
|
1085
|
+
job_type = str(result["scope"].get("job_type") or "")
|
|
1086
|
+
result["binding_digest"] = approval_binding_digest(
|
|
1087
|
+
job_id=result["job_id"],
|
|
1088
|
+
job_type=job_type,
|
|
1089
|
+
action_digest=result["action_digest"],
|
|
1090
|
+
scope_digest=result["scope_digest"],
|
|
1091
|
+
)
|
|
1092
|
+
request_digest_version = 2 if subject is not None else 1
|
|
1093
|
+
request_digest_payload = {
|
|
1094
|
+
"version": request_digest_version,
|
|
1095
|
+
"request_id": result["request_id"],
|
|
1096
|
+
"job_id": result["job_id"],
|
|
1097
|
+
"action_digest": result["action_digest"],
|
|
1098
|
+
"scope_digest": result["scope_digest"],
|
|
1099
|
+
"binding_digest": result["binding_digest"],
|
|
1100
|
+
"presentation_digest": result["presentation_digest"],
|
|
1101
|
+
"created_at": result["created_at"],
|
|
1102
|
+
"expires_at": result["expires_at"],
|
|
1103
|
+
}
|
|
1104
|
+
if subject is not None:
|
|
1105
|
+
request_digest_payload["subject_digest"] = computed_subject_digest
|
|
1106
|
+
result["request_digest"] = _digest(request_digest_payload)
|
|
1107
|
+
result["request_digest_version"] = request_digest_version
|
|
1108
|
+
return result
|
|
1109
|
+
|
|
1110
|
+
@staticmethod
|
|
1111
|
+
def _grant_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
|
1112
|
+
result = dict(row)
|
|
1113
|
+
result["scope"] = json.loads(result.pop("scope_json"))
|
|
1114
|
+
result["ttl_unbounded"] = bool(result.get("ttl_unbounded", 0))
|
|
1115
|
+
if result["ttl_unbounded"]:
|
|
1116
|
+
result["expires_at"] = None
|
|
1117
|
+
result["uses_unbounded"] = bool(result.get("uses_unbounded", 0))
|
|
1118
|
+
if result["uses_unbounded"]:
|
|
1119
|
+
result["max_uses"] = None
|
|
1120
|
+
return result
|
|
1121
|
+
|
|
1122
|
+
@staticmethod
|
|
1123
|
+
def _grant_use_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
|
1124
|
+
result = dict(row)
|
|
1125
|
+
result["grant_ttl_unbounded"] = bool(
|
|
1126
|
+
result.get("grant_ttl_unbounded", 0)
|
|
1127
|
+
)
|
|
1128
|
+
if result["grant_ttl_unbounded"]:
|
|
1129
|
+
result["grant_expires_at"] = None
|
|
1130
|
+
result["grant_uses_unbounded"] = bool(
|
|
1131
|
+
result.get("grant_uses_unbounded", 0)
|
|
1132
|
+
)
|
|
1133
|
+
if result["grant_uses_unbounded"]:
|
|
1134
|
+
result["max_uses"] = None
|
|
1135
|
+
return result
|
|
1136
|
+
|
|
1137
|
+
@classmethod
|
|
1138
|
+
def _request_view(
|
|
1139
|
+
cls,
|
|
1140
|
+
row: sqlite3.Row,
|
|
1141
|
+
*,
|
|
1142
|
+
now: Optional[datetime] = None,
|
|
1143
|
+
) -> Dict[str, Any]:
|
|
1144
|
+
result = cls._request_dict(row)
|
|
1145
|
+
if (
|
|
1146
|
+
result["status"] == "pending"
|
|
1147
|
+
and datetime.fromisoformat(result["expires_at"]) <= _as_utc(now)
|
|
1148
|
+
):
|
|
1149
|
+
# Read endpoints project expiry without mutating authority state.
|
|
1150
|
+
# Decision and scheduler transactions materialize it durably.
|
|
1151
|
+
result["status"] = "expired"
|
|
1152
|
+
return result
|
|
1153
|
+
|
|
1154
|
+
def _expire(self, conn: sqlite3.Connection, now: datetime) -> None:
|
|
1155
|
+
stamp = _iso(now)
|
|
1156
|
+
conn.execute(
|
|
1157
|
+
"UPDATE approval_requests SET status='expired' "
|
|
1158
|
+
"WHERE status='pending' AND expires_at<=?",
|
|
1159
|
+
(stamp,),
|
|
1160
|
+
)
|
|
1161
|
+
conn.execute(
|
|
1162
|
+
"UPDATE bounded_grants SET status='expired' "
|
|
1163
|
+
"WHERE status='active' AND ttl_unbounded=0 AND expires_at<=?",
|
|
1164
|
+
(stamp,),
|
|
1165
|
+
)
|
|
1166
|
+
conn.execute(
|
|
1167
|
+
"UPDATE bounded_grants SET status='exhausted' "
|
|
1168
|
+
"WHERE status='active' AND uses_unbounded=0 AND uses>=max_uses",
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
def resolve_action_gate(
|
|
1172
|
+
self,
|
|
1173
|
+
*,
|
|
1174
|
+
job_id: str,
|
|
1175
|
+
binding: ActionBinding,
|
|
1176
|
+
operation_id: str,
|
|
1177
|
+
ttl_seconds: int,
|
|
1178
|
+
presentation: Mapping[str, Any],
|
|
1179
|
+
now: Optional[datetime] = None,
|
|
1180
|
+
) -> Dict[str, Any]:
|
|
1181
|
+
"""Atomically choose an existing request, a grant, or a new request.
|
|
1182
|
+
|
|
1183
|
+
Approval state and reusable grants share this SQLite database, so one
|
|
1184
|
+
``BEGIN IMMEDIATE`` transaction can impose the authority ordering that
|
|
1185
|
+
the separate queue database cannot: any durable request for this job
|
|
1186
|
+
wins before a grant is considered. If no request exists, exactly one
|
|
1187
|
+
concurrent resolver may consume a grant or create the pending request.
|
|
1188
|
+
The queue transition remains a separately reconciled operation.
|
|
1189
|
+
"""
|
|
1190
|
+
|
|
1191
|
+
if ttl_seconds < 0 or ttl_seconds > MAX_APPROVAL_REQUEST_TTL_SECONDS:
|
|
1192
|
+
raise ApprovalAuthorityError(
|
|
1193
|
+
"invalid_expiry", "request TTL is out of bounds"
|
|
1194
|
+
)
|
|
1195
|
+
operation = str(operation_id or "").strip()
|
|
1196
|
+
if not operation:
|
|
1197
|
+
raise ApprovalAuthorityError(
|
|
1198
|
+
"operation_id_required", "operation_id is required"
|
|
1199
|
+
)
|
|
1200
|
+
normalized_presentation = _validated_presentation(presentation)
|
|
1201
|
+
presentation_json = _canonical(normalized_presentation)
|
|
1202
|
+
presentation_digest = _digest(normalized_presentation)
|
|
1203
|
+
observed = _as_utc(now)
|
|
1204
|
+
request_id = "apr_" + uuid.uuid4().hex
|
|
1205
|
+
|
|
1206
|
+
with self._connect() as conn:
|
|
1207
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1208
|
+
self._expire(conn, observed)
|
|
1209
|
+
current = conn.execute(
|
|
1210
|
+
"SELECT * FROM approval_requests WHERE job_id=? "
|
|
1211
|
+
"ORDER BY created_at DESC, request_id DESC LIMIT 1",
|
|
1212
|
+
(str(job_id),),
|
|
1213
|
+
).fetchone()
|
|
1214
|
+
if current is not None:
|
|
1215
|
+
if (
|
|
1216
|
+
current["action_digest"] != binding.action_digest
|
|
1217
|
+
or current["scope_digest"] != binding.scope_digest
|
|
1218
|
+
or _canonical(json.loads(current["scope_json"]))
|
|
1219
|
+
!= _canonical(binding.scope)
|
|
1220
|
+
):
|
|
1221
|
+
conn.rollback()
|
|
1222
|
+
raise ApprovalAuthorityError(
|
|
1223
|
+
"job_binding_conflict",
|
|
1224
|
+
"an approval request already binds this immutable job to another action",
|
|
1225
|
+
)
|
|
1226
|
+
stored_presentation_digest = str(
|
|
1227
|
+
current["presentation_digest"] or ""
|
|
1228
|
+
)
|
|
1229
|
+
if (
|
|
1230
|
+
stored_presentation_digest
|
|
1231
|
+
and stored_presentation_digest != presentation_digest
|
|
1232
|
+
):
|
|
1233
|
+
conn.rollback()
|
|
1234
|
+
raise ApprovalAuthorityError(
|
|
1235
|
+
"presentation_mismatch",
|
|
1236
|
+
"approval presentation changed for an immutable action",
|
|
1237
|
+
)
|
|
1238
|
+
if not stored_presentation_digest:
|
|
1239
|
+
conn.execute(
|
|
1240
|
+
"UPDATE approval_requests SET presentation_json=?, "
|
|
1241
|
+
"presentation_digest=? WHERE request_id=? "
|
|
1242
|
+
"AND presentation_digest IS NULL",
|
|
1243
|
+
(
|
|
1244
|
+
presentation_json,
|
|
1245
|
+
presentation_digest,
|
|
1246
|
+
current["request_id"],
|
|
1247
|
+
),
|
|
1248
|
+
)
|
|
1249
|
+
current = conn.execute(
|
|
1250
|
+
"SELECT * FROM approval_requests WHERE request_id=?",
|
|
1251
|
+
(current["request_id"],),
|
|
1252
|
+
).fetchone()
|
|
1253
|
+
conn.commit()
|
|
1254
|
+
assert current is not None
|
|
1255
|
+
return {
|
|
1256
|
+
"kind": "request",
|
|
1257
|
+
"request": self._request_dict(current),
|
|
1258
|
+
"grant": None,
|
|
1259
|
+
"grant_use": None,
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
# A job whose approval window closed cannot spend a reusable grant.
|
|
1263
|
+
# Returning no durable row is safe: the queue writes a terminal
|
|
1264
|
+
# state, and a crash simply retries this same deterministic result.
|
|
1265
|
+
if ttl_seconds == 0:
|
|
1266
|
+
conn.commit()
|
|
1267
|
+
return {
|
|
1268
|
+
"kind": "deadline_expired",
|
|
1269
|
+
"request": None,
|
|
1270
|
+
"grant": None,
|
|
1271
|
+
"grant_use": None,
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
use_row = conn.execute(
|
|
1275
|
+
"""SELECT u.action_digest, u.grant_id, u.operation_id,
|
|
1276
|
+
u.consumed_at, g.scope_digest, g.source_request_id,
|
|
1277
|
+
g.granted_by, g.decision_id, g.status AS grant_status,
|
|
1278
|
+
g.created_at AS grant_created_at,
|
|
1279
|
+
g.expires_at AS grant_expires_at,
|
|
1280
|
+
g.ttl_unbounded AS grant_ttl_unbounded,
|
|
1281
|
+
g.uses_unbounded AS grant_uses_unbounded,
|
|
1282
|
+
g.max_uses, g.uses
|
|
1283
|
+
FROM bounded_grant_uses AS u
|
|
1284
|
+
JOIN bounded_grants AS g ON g.grant_id = u.grant_id
|
|
1285
|
+
WHERE u.action_digest = ?""",
|
|
1286
|
+
(binding.action_digest,),
|
|
1287
|
+
).fetchone()
|
|
1288
|
+
if use_row is not None:
|
|
1289
|
+
if use_row["operation_id"] != operation:
|
|
1290
|
+
conn.rollback()
|
|
1291
|
+
raise ApprovalAuthorityError(
|
|
1292
|
+
"grant_operation_conflict",
|
|
1293
|
+
"bounded grant use belongs to another operation",
|
|
1294
|
+
)
|
|
1295
|
+
conn.commit()
|
|
1296
|
+
return {
|
|
1297
|
+
"kind": "grant",
|
|
1298
|
+
"request": None,
|
|
1299
|
+
"grant": None,
|
|
1300
|
+
"grant_use": self._grant_use_dict(use_row),
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
grant_row = conn.execute(
|
|
1304
|
+
"SELECT * FROM bounded_grants WHERE scope_digest=? "
|
|
1305
|
+
"AND status='active' "
|
|
1306
|
+
"AND (ttl_unbounded=1 OR expires_at>?) "
|
|
1307
|
+
"AND (uses_unbounded=1 OR uses<max_uses) "
|
|
1308
|
+
"ORDER BY ttl_unbounded ASC, expires_at ASC, created_at ASC "
|
|
1309
|
+
"LIMIT 1",
|
|
1310
|
+
(binding.scope_digest, _iso(observed)),
|
|
1311
|
+
).fetchone()
|
|
1312
|
+
if grant_row is not None:
|
|
1313
|
+
if _canonical(json.loads(grant_row["scope_json"])) != _canonical(
|
|
1314
|
+
binding.scope
|
|
1315
|
+
):
|
|
1316
|
+
conn.rollback()
|
|
1317
|
+
raise ApprovalAuthorityError(
|
|
1318
|
+
"scope_digest_collision", "grant scope mismatch"
|
|
1319
|
+
)
|
|
1320
|
+
conn.execute(
|
|
1321
|
+
"INSERT INTO bounded_grant_uses "
|
|
1322
|
+
"(action_digest, grant_id, operation_id, consumed_at) "
|
|
1323
|
+
"VALUES (?, ?, ?, ?)",
|
|
1324
|
+
(
|
|
1325
|
+
binding.action_digest,
|
|
1326
|
+
grant_row["grant_id"],
|
|
1327
|
+
operation,
|
|
1328
|
+
_iso(observed),
|
|
1329
|
+
),
|
|
1330
|
+
)
|
|
1331
|
+
conn.execute(
|
|
1332
|
+
"UPDATE bounded_grants SET uses=uses+1, "
|
|
1333
|
+
"status=CASE WHEN uses_unbounded=0 AND uses+1>=max_uses "
|
|
1334
|
+
"THEN 'exhausted' ELSE status END WHERE grant_id=?",
|
|
1335
|
+
(grant_row["grant_id"],),
|
|
1336
|
+
)
|
|
1337
|
+
use_row = conn.execute(
|
|
1338
|
+
"""SELECT u.action_digest, u.grant_id, u.operation_id,
|
|
1339
|
+
u.consumed_at, g.scope_digest,
|
|
1340
|
+
g.source_request_id, g.granted_by, g.decision_id,
|
|
1341
|
+
g.status AS grant_status,
|
|
1342
|
+
g.created_at AS grant_created_at,
|
|
1343
|
+
g.expires_at AS grant_expires_at,
|
|
1344
|
+
g.ttl_unbounded AS grant_ttl_unbounded,
|
|
1345
|
+
g.uses_unbounded AS grant_uses_unbounded,
|
|
1346
|
+
g.max_uses, g.uses
|
|
1347
|
+
FROM bounded_grant_uses AS u
|
|
1348
|
+
JOIN bounded_grants AS g ON g.grant_id = u.grant_id
|
|
1349
|
+
WHERE u.action_digest = ?""",
|
|
1350
|
+
(binding.action_digest,),
|
|
1351
|
+
).fetchone()
|
|
1352
|
+
conn.commit()
|
|
1353
|
+
assert use_row is not None
|
|
1354
|
+
return {
|
|
1355
|
+
"kind": "grant",
|
|
1356
|
+
"request": None,
|
|
1357
|
+
"grant": self._grant_dict(grant_row),
|
|
1358
|
+
"grant_use": self._grant_use_dict(use_row),
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
conn.execute(
|
|
1362
|
+
"INSERT INTO approval_requests "
|
|
1363
|
+
"(request_id, job_id, action_digest, scope_json, scope_digest, "
|
|
1364
|
+
"status, created_at, expires_at, presentation_json, "
|
|
1365
|
+
"presentation_digest) "
|
|
1366
|
+
"VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)",
|
|
1367
|
+
(
|
|
1368
|
+
request_id,
|
|
1369
|
+
str(job_id),
|
|
1370
|
+
binding.action_digest,
|
|
1371
|
+
_canonical(binding.scope),
|
|
1372
|
+
binding.scope_digest,
|
|
1373
|
+
_iso(observed),
|
|
1374
|
+
_iso(observed + timedelta(seconds=ttl_seconds)),
|
|
1375
|
+
presentation_json,
|
|
1376
|
+
presentation_digest,
|
|
1377
|
+
),
|
|
1378
|
+
)
|
|
1379
|
+
request_row = conn.execute(
|
|
1380
|
+
"SELECT * FROM approval_requests WHERE request_id=?",
|
|
1381
|
+
(request_id,),
|
|
1382
|
+
).fetchone()
|
|
1383
|
+
conn.commit()
|
|
1384
|
+
assert request_row is not None
|
|
1385
|
+
return {
|
|
1386
|
+
"kind": "request",
|
|
1387
|
+
"request": self._request_dict(request_row),
|
|
1388
|
+
"grant": None,
|
|
1389
|
+
"grant_use": None,
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
def ensure_request(
|
|
1393
|
+
self,
|
|
1394
|
+
*,
|
|
1395
|
+
job_id: str,
|
|
1396
|
+
binding: ActionBinding,
|
|
1397
|
+
ttl_seconds: int = DEFAULT_REQUEST_TTL_SECONDS,
|
|
1398
|
+
presentation: Optional[Mapping[str, Any]] = None,
|
|
1399
|
+
subject: Optional[
|
|
1400
|
+
ApprovalSubjectBinding | Mapping[str, Any]
|
|
1401
|
+
] = None,
|
|
1402
|
+
now: Optional[datetime] = None,
|
|
1403
|
+
) -> Dict[str, Any]:
|
|
1404
|
+
"""Return the current request, superseding any changed pending one.
|
|
1405
|
+
|
|
1406
|
+
A typed non-queue subject must be supplied when its request is first
|
|
1407
|
+
created. It is deliberately not backfilled onto an existing row: that
|
|
1408
|
+
would let an orphan or unrelated action be relabelled for a public
|
|
1409
|
+
decision surface after the fact.
|
|
1410
|
+
"""
|
|
1411
|
+
|
|
1412
|
+
if ttl_seconds < 60 or ttl_seconds > MAX_APPROVAL_REQUEST_TTL_SECONDS:
|
|
1413
|
+
raise ApprovalAuthorityError("invalid_expiry", "request TTL is out of bounds")
|
|
1414
|
+
observed = _as_utc(now)
|
|
1415
|
+
presentation_supplied = presentation is not None
|
|
1416
|
+
fallback_presentation = _validated_presentation({
|
|
1417
|
+
"schema": PRESENTATION_SCHEMA,
|
|
1418
|
+
"version": 1,
|
|
1419
|
+
"summary": binding.scope.get("action_name") or "approval required",
|
|
1420
|
+
"action_name": binding.scope.get("action_name") or "legacy_action",
|
|
1421
|
+
"risk": binding.scope.get("risk") or "unknown",
|
|
1422
|
+
"effect": "unknown",
|
|
1423
|
+
"target": "unspecified",
|
|
1424
|
+
"capabilities": [],
|
|
1425
|
+
"deadline": "unspecified",
|
|
1426
|
+
"reversibility": "unspecified",
|
|
1427
|
+
"constraints": binding.scope.get("constraints") or {},
|
|
1428
|
+
})
|
|
1429
|
+
if presentation is None:
|
|
1430
|
+
normalized_presentation = fallback_presentation
|
|
1431
|
+
else:
|
|
1432
|
+
normalized_presentation = _validated_presentation(presentation)
|
|
1433
|
+
presentation_json = _canonical(normalized_presentation)
|
|
1434
|
+
presentation_digest = _digest(normalized_presentation)
|
|
1435
|
+
normalized_subject: Optional[Dict[str, str]] = None
|
|
1436
|
+
subject_digest: Optional[str] = None
|
|
1437
|
+
if subject is not None:
|
|
1438
|
+
normalized_subject = (
|
|
1439
|
+
subject.payload()
|
|
1440
|
+
if isinstance(subject, ApprovalSubjectBinding)
|
|
1441
|
+
else _validated_subject(subject)
|
|
1442
|
+
)
|
|
1443
|
+
subject_digest = approval_subject_digest(
|
|
1444
|
+
normalized_subject,
|
|
1445
|
+
action_digest=binding.action_digest,
|
|
1446
|
+
scope_digest=binding.scope_digest,
|
|
1447
|
+
presentation_digest=presentation_digest,
|
|
1448
|
+
)
|
|
1449
|
+
request_id = "apr_" + uuid.uuid4().hex
|
|
1450
|
+
with self._connect() as conn:
|
|
1451
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1452
|
+
self._expire(conn, observed)
|
|
1453
|
+
current = conn.execute(
|
|
1454
|
+
"SELECT * FROM approval_requests WHERE job_id=? "
|
|
1455
|
+
"ORDER BY created_at DESC LIMIT 1",
|
|
1456
|
+
(str(job_id),),
|
|
1457
|
+
).fetchone()
|
|
1458
|
+
if (
|
|
1459
|
+
current is not None
|
|
1460
|
+
and current["action_digest"] == binding.action_digest
|
|
1461
|
+
and current["scope_digest"] == binding.scope_digest
|
|
1462
|
+
):
|
|
1463
|
+
current_presentation_digest = str(
|
|
1464
|
+
current["presentation_digest"] or ""
|
|
1465
|
+
)
|
|
1466
|
+
if current_presentation_digest and presentation_supplied and (
|
|
1467
|
+
current_presentation_digest != presentation_digest
|
|
1468
|
+
):
|
|
1469
|
+
conn.rollback()
|
|
1470
|
+
raise ApprovalAuthorityError(
|
|
1471
|
+
"presentation_mismatch",
|
|
1472
|
+
"approval presentation changed for an immutable action",
|
|
1473
|
+
)
|
|
1474
|
+
if not current_presentation_digest and presentation_supplied:
|
|
1475
|
+
# Migration-only backfill. The action digest already binds
|
|
1476
|
+
# the source payload from which this redacted view derives.
|
|
1477
|
+
conn.execute(
|
|
1478
|
+
"UPDATE approval_requests SET presentation_json=?, "
|
|
1479
|
+
"presentation_digest=? WHERE request_id=? "
|
|
1480
|
+
"AND presentation_digest IS NULL",
|
|
1481
|
+
(
|
|
1482
|
+
presentation_json,
|
|
1483
|
+
presentation_digest,
|
|
1484
|
+
current["request_id"],
|
|
1485
|
+
),
|
|
1486
|
+
)
|
|
1487
|
+
current = conn.execute(
|
|
1488
|
+
"SELECT * FROM approval_requests WHERE request_id=?",
|
|
1489
|
+
(current["request_id"],),
|
|
1490
|
+
).fetchone()
|
|
1491
|
+
current_subject_values = (
|
|
1492
|
+
current["subject_kind"], current["subject_id"],
|
|
1493
|
+
current["subject_revision"], current["subject_action"],
|
|
1494
|
+
)
|
|
1495
|
+
current_has_subject = any(
|
|
1496
|
+
value not in (None, "") for value in current_subject_values
|
|
1497
|
+
)
|
|
1498
|
+
if normalized_subject is not None:
|
|
1499
|
+
if not current_has_subject:
|
|
1500
|
+
conn.rollback()
|
|
1501
|
+
raise ApprovalAuthorityError(
|
|
1502
|
+
"subject_binding_conflict",
|
|
1503
|
+
"an existing approval request cannot be relabelled as a typed subject",
|
|
1504
|
+
)
|
|
1505
|
+
expected_values = (
|
|
1506
|
+
normalized_subject["kind"],
|
|
1507
|
+
normalized_subject["subject_id"],
|
|
1508
|
+
normalized_subject["revision"],
|
|
1509
|
+
normalized_subject["action"],
|
|
1510
|
+
)
|
|
1511
|
+
if (
|
|
1512
|
+
current_subject_values != expected_values
|
|
1513
|
+
or current["subject_digest"] != subject_digest
|
|
1514
|
+
):
|
|
1515
|
+
conn.rollback()
|
|
1516
|
+
raise ApprovalAuthorityError(
|
|
1517
|
+
"subject_binding_conflict",
|
|
1518
|
+
"approval request subject binding is immutable",
|
|
1519
|
+
)
|
|
1520
|
+
conn.commit()
|
|
1521
|
+
return self._request_dict(current)
|
|
1522
|
+
|
|
1523
|
+
if current is not None and (
|
|
1524
|
+
normalized_subject is not None
|
|
1525
|
+
or current["subject_kind"] not in (None, "")
|
|
1526
|
+
):
|
|
1527
|
+
conn.rollback()
|
|
1528
|
+
raise ApprovalAuthorityError(
|
|
1529
|
+
"subject_binding_conflict",
|
|
1530
|
+
"a typed approval subject cannot change its immutable action binding",
|
|
1531
|
+
)
|
|
1532
|
+
|
|
1533
|
+
if current is not None and current["status"] == "pending":
|
|
1534
|
+
conn.execute(
|
|
1535
|
+
"UPDATE approval_requests SET status='superseded', "
|
|
1536
|
+
"superseded_by=? WHERE request_id=? AND status='pending'",
|
|
1537
|
+
(request_id, current["request_id"]),
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
conn.execute(
|
|
1541
|
+
"INSERT INTO approval_requests "
|
|
1542
|
+
"(request_id, job_id, action_digest, scope_json, scope_digest, "
|
|
1543
|
+
"status, created_at, expires_at, presentation_json, "
|
|
1544
|
+
"presentation_digest, subject_kind, subject_id, "
|
|
1545
|
+
"subject_revision, subject_action, subject_digest) "
|
|
1546
|
+
"VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1547
|
+
(
|
|
1548
|
+
request_id,
|
|
1549
|
+
str(job_id),
|
|
1550
|
+
binding.action_digest,
|
|
1551
|
+
_canonical(binding.scope),
|
|
1552
|
+
binding.scope_digest,
|
|
1553
|
+
_iso(observed),
|
|
1554
|
+
_iso(observed + timedelta(seconds=ttl_seconds)),
|
|
1555
|
+
presentation_json,
|
|
1556
|
+
presentation_digest,
|
|
1557
|
+
normalized_subject["kind"] if normalized_subject else None,
|
|
1558
|
+
normalized_subject["subject_id"] if normalized_subject else None,
|
|
1559
|
+
normalized_subject["revision"] if normalized_subject else None,
|
|
1560
|
+
normalized_subject["action"] if normalized_subject else None,
|
|
1561
|
+
subject_digest,
|
|
1562
|
+
),
|
|
1563
|
+
)
|
|
1564
|
+
row = conn.execute(
|
|
1565
|
+
"SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
|
|
1566
|
+
).fetchone()
|
|
1567
|
+
conn.commit()
|
|
1568
|
+
assert row is not None
|
|
1569
|
+
return self._request_dict(row)
|
|
1570
|
+
|
|
1571
|
+
def get_request(self, request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
|
|
1572
|
+
with self._connect() as conn:
|
|
1573
|
+
row = conn.execute(
|
|
1574
|
+
"SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
|
|
1575
|
+
).fetchone()
|
|
1576
|
+
return self._request_view(row, now=now) if row is not None else None
|
|
1577
|
+
|
|
1578
|
+
def get_request_for_job(
|
|
1579
|
+
self,
|
|
1580
|
+
job_id: str,
|
|
1581
|
+
*,
|
|
1582
|
+
now: Optional[datetime] = None,
|
|
1583
|
+
) -> Optional[Dict[str, Any]]:
|
|
1584
|
+
"""Return the latest canonical request for one immutable queue job."""
|
|
1585
|
+
|
|
1586
|
+
with self._connect() as conn:
|
|
1587
|
+
row = conn.execute(
|
|
1588
|
+
"SELECT * FROM approval_requests WHERE job_id=? "
|
|
1589
|
+
"ORDER BY created_at DESC, request_id DESC LIMIT 1",
|
|
1590
|
+
(str(job_id),),
|
|
1591
|
+
).fetchone()
|
|
1592
|
+
return self._request_view(row, now=now) if row is not None else None
|
|
1593
|
+
|
|
1594
|
+
def list_requests(
|
|
1595
|
+
self,
|
|
1596
|
+
*,
|
|
1597
|
+
status: Optional[str] = None,
|
|
1598
|
+
limit: int = 100,
|
|
1599
|
+
now: Optional[datetime] = None,
|
|
1600
|
+
) -> list[Dict[str, Any]]:
|
|
1601
|
+
bounded_limit = max(1, min(int(limit), 500))
|
|
1602
|
+
observed = _as_utc(now)
|
|
1603
|
+
with self._connect() as conn:
|
|
1604
|
+
if status == "pending":
|
|
1605
|
+
rows = conn.execute(
|
|
1606
|
+
"SELECT * FROM approval_requests WHERE status='pending' "
|
|
1607
|
+
"AND expires_at>? ORDER BY created_at DESC LIMIT ?",
|
|
1608
|
+
(_iso(observed), bounded_limit),
|
|
1609
|
+
).fetchall()
|
|
1610
|
+
elif status == "expired":
|
|
1611
|
+
rows = conn.execute(
|
|
1612
|
+
"SELECT * FROM approval_requests WHERE status='expired' "
|
|
1613
|
+
"OR (status='pending' AND expires_at<=?) "
|
|
1614
|
+
"ORDER BY created_at DESC LIMIT ?",
|
|
1615
|
+
(_iso(observed), bounded_limit),
|
|
1616
|
+
).fetchall()
|
|
1617
|
+
elif status:
|
|
1618
|
+
rows = conn.execute(
|
|
1619
|
+
"SELECT * FROM approval_requests WHERE status=? "
|
|
1620
|
+
"ORDER BY created_at DESC LIMIT ?",
|
|
1621
|
+
(status, bounded_limit),
|
|
1622
|
+
).fetchall()
|
|
1623
|
+
else:
|
|
1624
|
+
rows = conn.execute(
|
|
1625
|
+
"SELECT * FROM approval_requests ORDER BY created_at DESC LIMIT ?",
|
|
1626
|
+
(bounded_limit,),
|
|
1627
|
+
).fetchall()
|
|
1628
|
+
return [self._request_view(row, now=observed) for row in rows]
|
|
1629
|
+
|
|
1630
|
+
def list_subject_requests(
|
|
1631
|
+
self,
|
|
1632
|
+
*,
|
|
1633
|
+
subject_kind: str,
|
|
1634
|
+
status: Optional[str] = None,
|
|
1635
|
+
limit: int = 100,
|
|
1636
|
+
now: Optional[datetime] = None,
|
|
1637
|
+
) -> list[Dict[str, Any]]:
|
|
1638
|
+
"""Return typed candidates for a domain service to validate.
|
|
1639
|
+
|
|
1640
|
+
This is intentionally not itself a public authorization boundary. The
|
|
1641
|
+
owning domain must still prove the subject exists and recompute its
|
|
1642
|
+
domain binding before projecting or deciding a candidate.
|
|
1643
|
+
"""
|
|
1644
|
+
|
|
1645
|
+
inspection = self.inspect_subject_requests(
|
|
1646
|
+
subject_kind=subject_kind,
|
|
1647
|
+
status=status,
|
|
1648
|
+
limit=limit,
|
|
1649
|
+
now=now,
|
|
1650
|
+
)
|
|
1651
|
+
return inspection["requests"]
|
|
1652
|
+
|
|
1653
|
+
def inspect_subject_requests(
|
|
1654
|
+
self,
|
|
1655
|
+
*,
|
|
1656
|
+
subject_kind: str,
|
|
1657
|
+
status: Optional[str] = None,
|
|
1658
|
+
limit: int = 100,
|
|
1659
|
+
job_prefix: Optional[str] = None,
|
|
1660
|
+
now: Optional[datetime] = None,
|
|
1661
|
+
) -> Dict[str, Any]:
|
|
1662
|
+
"""Inspect typed candidates while counting undisclosed invalid rows."""
|
|
1663
|
+
|
|
1664
|
+
kind = str(subject_kind or "").strip()
|
|
1665
|
+
if not _SUBJECT_KIND_RE.fullmatch(kind):
|
|
1666
|
+
raise ApprovalAuthorityError(
|
|
1667
|
+
"invalid_subject_binding", "approval subject kind is invalid",
|
|
1668
|
+
)
|
|
1669
|
+
bounded_limit = max(1, min(int(limit), 500))
|
|
1670
|
+
observed = _as_utc(now)
|
|
1671
|
+
prefix = str(job_prefix or "").strip()
|
|
1672
|
+
if prefix and (
|
|
1673
|
+
len(prefix) > 128 or _CONTROL.search(prefix)
|
|
1674
|
+
or not _ACTION_RE.fullmatch(prefix.rstrip(":"))
|
|
1675
|
+
):
|
|
1676
|
+
raise ApprovalAuthorityError(
|
|
1677
|
+
"invalid_subject_binding", "approval job prefix is invalid",
|
|
1678
|
+
)
|
|
1679
|
+
predicates = [
|
|
1680
|
+
"(subject_kind=? OR job_id LIKE ? ESCAPE '\\')"
|
|
1681
|
+
if prefix else "subject_kind=?",
|
|
1682
|
+
]
|
|
1683
|
+
values: list[Any] = [kind]
|
|
1684
|
+
if prefix:
|
|
1685
|
+
values.append(prefix.replace("%", "\\%").replace("_", "\\_") + "%")
|
|
1686
|
+
if status == "pending":
|
|
1687
|
+
predicates.extend(["status='pending'", "expires_at>?"])
|
|
1688
|
+
values.append(_iso(observed))
|
|
1689
|
+
elif status == "expired":
|
|
1690
|
+
predicates.append(
|
|
1691
|
+
"(status='expired' OR (status='pending' AND expires_at<=?))"
|
|
1692
|
+
)
|
|
1693
|
+
values.append(_iso(observed))
|
|
1694
|
+
elif status:
|
|
1695
|
+
predicates.append("status=?")
|
|
1696
|
+
values.append(str(status))
|
|
1697
|
+
values.append(bounded_limit)
|
|
1698
|
+
with self._connect() as conn:
|
|
1699
|
+
rows = conn.execute(
|
|
1700
|
+
"SELECT * FROM approval_requests WHERE "
|
|
1701
|
+
+ " AND ".join(predicates)
|
|
1702
|
+
+ " ORDER BY created_at DESC, request_id DESC LIMIT ?",
|
|
1703
|
+
values,
|
|
1704
|
+
).fetchall()
|
|
1705
|
+
requests: list[Dict[str, Any]] = []
|
|
1706
|
+
invalid_count = 0
|
|
1707
|
+
for row in rows:
|
|
1708
|
+
try:
|
|
1709
|
+
request = self._request_view(row, now=observed)
|
|
1710
|
+
except ApprovalAuthorityError:
|
|
1711
|
+
invalid_count += 1
|
|
1712
|
+
continue
|
|
1713
|
+
subject = request.get("subject")
|
|
1714
|
+
if not isinstance(subject, Mapping) or subject.get("kind") != kind:
|
|
1715
|
+
invalid_count += 1
|
|
1716
|
+
continue
|
|
1717
|
+
requests.append(request)
|
|
1718
|
+
return {
|
|
1719
|
+
"requests": requests,
|
|
1720
|
+
"invalid_hidden_count": invalid_count,
|
|
1721
|
+
"candidate_count": len(rows),
|
|
1722
|
+
"complete": len(rows) < bounded_limit,
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
def decide(
|
|
1726
|
+
self,
|
|
1727
|
+
request_id: str,
|
|
1728
|
+
*,
|
|
1729
|
+
decision: str,
|
|
1730
|
+
decision_id: str,
|
|
1731
|
+
expected_action_digest: str,
|
|
1732
|
+
decided_by: str,
|
|
1733
|
+
authority_evidence: str,
|
|
1734
|
+
grant_scope: Optional[Mapping[str, Any]] = None,
|
|
1735
|
+
grant_ttl_seconds: int = DEFAULT_GRANT_TTL_SECONDS,
|
|
1736
|
+
grant_max_uses: int = DEFAULT_GRANT_MAX_USES,
|
|
1737
|
+
now: Optional[datetime] = None,
|
|
1738
|
+
) -> Dict[str, Any]:
|
|
1739
|
+
"""Record the first valid decision and optionally mint one grant.
|
|
1740
|
+
|
|
1741
|
+
Repeating the exact same decision id is idempotent. A different
|
|
1742
|
+
replay, an opposite decision, or a decision against changed content is
|
|
1743
|
+
rejected and cannot alter the winner.
|
|
1744
|
+
"""
|
|
1745
|
+
|
|
1746
|
+
normalized = str(decision).strip().lower()
|
|
1747
|
+
if normalized not in {"approve", "reject"}:
|
|
1748
|
+
raise ApprovalAuthorityError("invalid_decision", "decision must be approve or reject")
|
|
1749
|
+
if not _DECISION_ID_RE.fullmatch(str(decision_id or "")):
|
|
1750
|
+
raise ApprovalAuthorityError("invalid_decision_id", "decision_id is malformed")
|
|
1751
|
+
actor = str(decided_by or "").strip()
|
|
1752
|
+
evidence = str(authority_evidence or "").strip()
|
|
1753
|
+
if not actor or not evidence:
|
|
1754
|
+
raise ApprovalAuthorityError(
|
|
1755
|
+
"authority_required", "server-derived decision authority is required"
|
|
1756
|
+
)
|
|
1757
|
+
if len(evidence) > 512 or _CONTROL.search(evidence):
|
|
1758
|
+
raise ApprovalAuthorityError(
|
|
1759
|
+
"invalid_authority_evidence",
|
|
1760
|
+
"decision authority evidence is invalid or exceeds 512 characters",
|
|
1761
|
+
)
|
|
1762
|
+
ttl_unbounded = self.grant_envelope.max_ttl_seconds is None
|
|
1763
|
+
uses_unbounded = self.grant_envelope.max_uses is None
|
|
1764
|
+
if grant_scope is not None:
|
|
1765
|
+
if normalized != "approve":
|
|
1766
|
+
raise ApprovalAuthorityError("invalid_grant", "a rejection cannot create a grant")
|
|
1767
|
+
if grant_ttl_seconds < 60 or (
|
|
1768
|
+
self.grant_envelope.max_ttl_seconds is not None
|
|
1769
|
+
and grant_ttl_seconds > self.grant_envelope.max_ttl_seconds
|
|
1770
|
+
):
|
|
1771
|
+
raise ApprovalAuthorityError("invalid_grant_expiry", "grant expiry is out of bounds")
|
|
1772
|
+
if grant_max_uses < 1 or (
|
|
1773
|
+
self.grant_envelope.max_uses is not None
|
|
1774
|
+
and grant_max_uses > self.grant_envelope.max_uses
|
|
1775
|
+
):
|
|
1776
|
+
raise ApprovalAuthorityError("invalid_grant_uses", "grant use bound is out of bounds")
|
|
1777
|
+
|
|
1778
|
+
observed = _as_utc(now)
|
|
1779
|
+
grant_expires_at = observed
|
|
1780
|
+
if grant_scope is not None and not ttl_unbounded:
|
|
1781
|
+
try:
|
|
1782
|
+
grant_expires_at = observed + timedelta(seconds=grant_ttl_seconds)
|
|
1783
|
+
except OverflowError as exc:
|
|
1784
|
+
raise ApprovalAuthorityError(
|
|
1785
|
+
"invalid_grant_expiry", "grant expiry is out of bounds"
|
|
1786
|
+
) from exc
|
|
1787
|
+
effective_max_uses = 0 if uses_unbounded else int(grant_max_uses)
|
|
1788
|
+
with self._connect() as conn:
|
|
1789
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1790
|
+
self._expire(conn, observed)
|
|
1791
|
+
row = conn.execute(
|
|
1792
|
+
"SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
|
|
1793
|
+
).fetchone()
|
|
1794
|
+
if row is None:
|
|
1795
|
+
conn.rollback()
|
|
1796
|
+
raise ApprovalAuthorityError("request_not_found", "approval request was not found")
|
|
1797
|
+
if row["action_digest"] != expected_action_digest:
|
|
1798
|
+
conn.rollback()
|
|
1799
|
+
raise ApprovalAuthorityError(
|
|
1800
|
+
"stale_action_digest", "decision does not match the current immutable action"
|
|
1801
|
+
)
|
|
1802
|
+
|
|
1803
|
+
replayed_elsewhere = conn.execute(
|
|
1804
|
+
"SELECT request_id FROM approval_requests WHERE decision_id=?",
|
|
1805
|
+
(decision_id,),
|
|
1806
|
+
).fetchone()
|
|
1807
|
+
if (
|
|
1808
|
+
replayed_elsewhere is not None
|
|
1809
|
+
and replayed_elsewhere["request_id"] != request_id
|
|
1810
|
+
):
|
|
1811
|
+
conn.rollback()
|
|
1812
|
+
raise ApprovalAuthorityError(
|
|
1813
|
+
"decision_replay", "decision_id was already used for another request"
|
|
1814
|
+
)
|
|
1815
|
+
|
|
1816
|
+
status = row["status"]
|
|
1817
|
+
if status in {"approved", "rejected"}:
|
|
1818
|
+
if row["decision_id"] == decision_id and row["decision"] == normalized:
|
|
1819
|
+
grant = None
|
|
1820
|
+
if row["grant_id"]:
|
|
1821
|
+
grant_row = conn.execute(
|
|
1822
|
+
"SELECT * FROM bounded_grants WHERE grant_id=?", (row["grant_id"],)
|
|
1823
|
+
).fetchone()
|
|
1824
|
+
grant = self._grant_dict(grant_row) if grant_row is not None else None
|
|
1825
|
+
conn.commit()
|
|
1826
|
+
return {
|
|
1827
|
+
"request": self._request_dict(row),
|
|
1828
|
+
"grant": grant,
|
|
1829
|
+
"replayed": True,
|
|
1830
|
+
}
|
|
1831
|
+
conn.rollback()
|
|
1832
|
+
raise ApprovalAuthorityError(
|
|
1833
|
+
"decision_already_final", "the first valid decision is already final"
|
|
1834
|
+
)
|
|
1835
|
+
if status == "expired":
|
|
1836
|
+
conn.rollback()
|
|
1837
|
+
raise ApprovalAuthorityError("request_expired", "approval request has expired")
|
|
1838
|
+
if status == "superseded":
|
|
1839
|
+
conn.rollback()
|
|
1840
|
+
raise ApprovalAuthorityError("request_superseded", "approval request was superseded")
|
|
1841
|
+
if status != "pending":
|
|
1842
|
+
conn.rollback()
|
|
1843
|
+
raise ApprovalAuthorityError("request_not_pending", "approval request is not pending")
|
|
1844
|
+
|
|
1845
|
+
grant_id: Optional[str] = None
|
|
1846
|
+
grant: Optional[Dict[str, Any]] = None
|
|
1847
|
+
if grant_scope is not None:
|
|
1848
|
+
stored_scope = json.loads(row["scope_json"])
|
|
1849
|
+
if _canonical(dict(grant_scope)) != _canonical(stored_scope):
|
|
1850
|
+
conn.rollback()
|
|
1851
|
+
raise ApprovalAuthorityError(
|
|
1852
|
+
"scope_broadening",
|
|
1853
|
+
"bounded grant scope must exactly match the approved action scope",
|
|
1854
|
+
)
|
|
1855
|
+
grant_id = "grt_" + hashlib.sha256(
|
|
1856
|
+
f"{request_id}:{decision_id}".encode("utf-8")
|
|
1857
|
+
).hexdigest()[:32]
|
|
1858
|
+
conn.execute(
|
|
1859
|
+
"INSERT INTO bounded_grants "
|
|
1860
|
+
"(grant_id, source_request_id, scope_json, scope_digest, status, "
|
|
1861
|
+
"created_at, expires_at, ttl_unbounded, max_uses, "
|
|
1862
|
+
"uses_unbounded, uses, granted_by, decision_id) "
|
|
1863
|
+
"VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, 0, ?, ?)",
|
|
1864
|
+
(
|
|
1865
|
+
grant_id,
|
|
1866
|
+
request_id,
|
|
1867
|
+
row["scope_json"],
|
|
1868
|
+
row["scope_digest"],
|
|
1869
|
+
_iso(observed),
|
|
1870
|
+
_iso(grant_expires_at),
|
|
1871
|
+
int(ttl_unbounded),
|
|
1872
|
+
effective_max_uses,
|
|
1873
|
+
int(uses_unbounded),
|
|
1874
|
+
actor,
|
|
1875
|
+
decision_id,
|
|
1876
|
+
),
|
|
1877
|
+
)
|
|
1878
|
+
|
|
1879
|
+
final_status = "approved" if normalized == "approve" else "rejected"
|
|
1880
|
+
conn.execute(
|
|
1881
|
+
"UPDATE approval_requests SET status=?, decision=?, decision_id=?, "
|
|
1882
|
+
"decided_at=?, decided_by=?, authority_evidence=?, grant_id=? "
|
|
1883
|
+
"WHERE request_id=? AND status='pending'",
|
|
1884
|
+
(
|
|
1885
|
+
final_status,
|
|
1886
|
+
normalized,
|
|
1887
|
+
decision_id,
|
|
1888
|
+
_iso(observed),
|
|
1889
|
+
actor,
|
|
1890
|
+
evidence,
|
|
1891
|
+
grant_id,
|
|
1892
|
+
request_id,
|
|
1893
|
+
),
|
|
1894
|
+
)
|
|
1895
|
+
final_row = conn.execute(
|
|
1896
|
+
"SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
|
|
1897
|
+
).fetchone()
|
|
1898
|
+
if grant_id:
|
|
1899
|
+
grant_row = conn.execute(
|
|
1900
|
+
"SELECT * FROM bounded_grants WHERE grant_id=?", (grant_id,)
|
|
1901
|
+
).fetchone()
|
|
1902
|
+
grant = self._grant_dict(grant_row) if grant_row is not None else None
|
|
1903
|
+
conn.commit()
|
|
1904
|
+
assert final_row is not None
|
|
1905
|
+
return {
|
|
1906
|
+
"request": self._request_dict(final_row),
|
|
1907
|
+
"grant": grant,
|
|
1908
|
+
"replayed": False,
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
def supersede(
|
|
1912
|
+
self,
|
|
1913
|
+
request_id: str,
|
|
1914
|
+
*,
|
|
1915
|
+
replaced_by: str,
|
|
1916
|
+
) -> Dict[str, Any]:
|
|
1917
|
+
with self._connect() as conn:
|
|
1918
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1919
|
+
row = conn.execute(
|
|
1920
|
+
"SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
|
|
1921
|
+
).fetchone()
|
|
1922
|
+
if row is None:
|
|
1923
|
+
conn.rollback()
|
|
1924
|
+
raise ApprovalAuthorityError("request_not_found", "approval request was not found")
|
|
1925
|
+
if row["status"] != "pending":
|
|
1926
|
+
conn.rollback()
|
|
1927
|
+
raise ApprovalAuthorityError(
|
|
1928
|
+
"request_not_pending", "only a pending request can be superseded"
|
|
1929
|
+
)
|
|
1930
|
+
conn.execute(
|
|
1931
|
+
"UPDATE approval_requests SET status='superseded', superseded_by=? "
|
|
1932
|
+
"WHERE request_id=? AND status='pending'",
|
|
1933
|
+
(str(replaced_by), request_id),
|
|
1934
|
+
)
|
|
1935
|
+
final_row = conn.execute(
|
|
1936
|
+
"SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
|
|
1937
|
+
).fetchone()
|
|
1938
|
+
conn.commit()
|
|
1939
|
+
assert final_row is not None
|
|
1940
|
+
return self._request_dict(final_row)
|
|
1941
|
+
|
|
1942
|
+
def consume_grant(
|
|
1943
|
+
self,
|
|
1944
|
+
*,
|
|
1945
|
+
binding: ActionBinding,
|
|
1946
|
+
operation_id: str,
|
|
1947
|
+
now: Optional[datetime] = None,
|
|
1948
|
+
) -> Optional[Dict[str, Any]]:
|
|
1949
|
+
"""Atomically consume one matching use; retries are idempotent."""
|
|
1950
|
+
|
|
1951
|
+
observed = _as_utc(now)
|
|
1952
|
+
operation = str(operation_id or "").strip()
|
|
1953
|
+
if not operation:
|
|
1954
|
+
raise ApprovalAuthorityError("operation_id_required", "operation_id is required")
|
|
1955
|
+
with self._connect() as conn:
|
|
1956
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1957
|
+
self._expire(conn, observed)
|
|
1958
|
+
existing = conn.execute(
|
|
1959
|
+
"SELECT g.* FROM bounded_grant_uses u "
|
|
1960
|
+
"JOIN bounded_grants g ON g.grant_id=u.grant_id "
|
|
1961
|
+
"WHERE u.action_digest=?",
|
|
1962
|
+
(binding.action_digest,),
|
|
1963
|
+
).fetchone()
|
|
1964
|
+
if existing is not None:
|
|
1965
|
+
conn.commit()
|
|
1966
|
+
result = self._grant_dict(existing)
|
|
1967
|
+
result["idempotent_reuse"] = True
|
|
1968
|
+
return result
|
|
1969
|
+
|
|
1970
|
+
row = conn.execute(
|
|
1971
|
+
"SELECT * FROM bounded_grants WHERE scope_digest=? "
|
|
1972
|
+
"AND status='active' "
|
|
1973
|
+
"AND (ttl_unbounded=1 OR expires_at>?) "
|
|
1974
|
+
"AND (uses_unbounded=1 OR uses<max_uses) "
|
|
1975
|
+
"ORDER BY ttl_unbounded ASC, expires_at ASC, created_at ASC "
|
|
1976
|
+
"LIMIT 1",
|
|
1977
|
+
(binding.scope_digest, _iso(observed)),
|
|
1978
|
+
).fetchone()
|
|
1979
|
+
if row is None:
|
|
1980
|
+
conn.commit()
|
|
1981
|
+
return None
|
|
1982
|
+
if _canonical(json.loads(row["scope_json"])) != _canonical(binding.scope):
|
|
1983
|
+
# Hash collisions are fantastically unlikely, but authority
|
|
1984
|
+
# code compares canonical scope too instead of relying on one.
|
|
1985
|
+
conn.rollback()
|
|
1986
|
+
raise ApprovalAuthorityError("scope_digest_collision", "grant scope mismatch")
|
|
1987
|
+
|
|
1988
|
+
conn.execute(
|
|
1989
|
+
"INSERT INTO bounded_grant_uses "
|
|
1990
|
+
"(action_digest, grant_id, operation_id, consumed_at) VALUES (?, ?, ?, ?)",
|
|
1991
|
+
(binding.action_digest, row["grant_id"], operation, _iso(observed)),
|
|
1992
|
+
)
|
|
1993
|
+
conn.execute(
|
|
1994
|
+
"UPDATE bounded_grants SET uses=uses+1, "
|
|
1995
|
+
"status=CASE WHEN uses_unbounded=0 AND uses+1>=max_uses "
|
|
1996
|
+
"THEN 'exhausted' ELSE status END "
|
|
1997
|
+
"WHERE grant_id=?",
|
|
1998
|
+
(row["grant_id"],),
|
|
1999
|
+
)
|
|
2000
|
+
final_row = conn.execute(
|
|
2001
|
+
"SELECT * FROM bounded_grants WHERE grant_id=?", (row["grant_id"],)
|
|
2002
|
+
).fetchone()
|
|
2003
|
+
conn.commit()
|
|
2004
|
+
assert final_row is not None
|
|
2005
|
+
result = self._grant_dict(final_row)
|
|
2006
|
+
result["idempotent_reuse"] = False
|
|
2007
|
+
return result
|
|
2008
|
+
|
|
2009
|
+
def list_grants(
|
|
2010
|
+
self,
|
|
2011
|
+
*,
|
|
2012
|
+
status: Optional[str] = None,
|
|
2013
|
+
now: Optional[datetime] = None,
|
|
2014
|
+
) -> list[Dict[str, Any]]:
|
|
2015
|
+
with self._connect() as conn:
|
|
2016
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
2017
|
+
self._expire(conn, _as_utc(now))
|
|
2018
|
+
if status:
|
|
2019
|
+
rows = conn.execute(
|
|
2020
|
+
"SELECT * FROM bounded_grants WHERE status=? ORDER BY created_at DESC",
|
|
2021
|
+
(status,),
|
|
2022
|
+
).fetchall()
|
|
2023
|
+
else:
|
|
2024
|
+
rows = conn.execute(
|
|
2025
|
+
"SELECT * FROM bounded_grants ORDER BY created_at DESC"
|
|
2026
|
+
).fetchall()
|
|
2027
|
+
conn.commit()
|
|
2028
|
+
return [self._grant_dict(row) for row in rows]
|
|
2029
|
+
|
|
2030
|
+
def grant_posture(self, *, now: Optional[datetime] = None) -> Dict[str, Any]:
|
|
2031
|
+
"""Return the issuance envelope plus active persisted standing state.
|
|
2032
|
+
|
|
2033
|
+
Changing the deployment envelope affects newly issued grants; it does
|
|
2034
|
+
not silently narrow authority already granted by an owner decision.
|
|
2035
|
+
Report that durable inventory so a bounded restart can never make
|
|
2036
|
+
active standing authority disappear from operator diagnostics.
|
|
2037
|
+
"""
|
|
2038
|
+
|
|
2039
|
+
observed = _as_utc(now)
|
|
2040
|
+
with self._connect() as conn:
|
|
2041
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
2042
|
+
self._expire(conn, observed)
|
|
2043
|
+
row = conn.execute(
|
|
2044
|
+
"SELECT COUNT(*) AS active_standing_grants, "
|
|
2045
|
+
"SUM(CASE WHEN ttl_unbounded=1 THEN 1 ELSE 0 END) "
|
|
2046
|
+
"AS active_no_expiry_grants, "
|
|
2047
|
+
"SUM(CASE WHEN uses_unbounded=1 THEN 1 ELSE 0 END) "
|
|
2048
|
+
"AS active_no_use_cap_grants "
|
|
2049
|
+
"FROM bounded_grants WHERE status='active' "
|
|
2050
|
+
"AND (ttl_unbounded=1 OR uses_unbounded=1)"
|
|
2051
|
+
).fetchone()
|
|
2052
|
+
conn.commit()
|
|
2053
|
+
posture = grant_envelope_posture(self.grant_envelope)
|
|
2054
|
+
posture.update({
|
|
2055
|
+
"active_standing_grants": int(row["active_standing_grants"] or 0),
|
|
2056
|
+
"active_no_expiry_grants": int(
|
|
2057
|
+
row["active_no_expiry_grants"] or 0
|
|
2058
|
+
),
|
|
2059
|
+
"active_no_use_cap_grants": int(
|
|
2060
|
+
row["active_no_use_cap_grants"] or 0
|
|
2061
|
+
),
|
|
2062
|
+
})
|
|
2063
|
+
return posture
|
|
2064
|
+
|
|
2065
|
+
def get_grant_use(self, action_digest: str) -> Optional[Dict[str, Any]]:
|
|
2066
|
+
"""Return durable bounded-grant consumption evidence for one action."""
|
|
2067
|
+
|
|
2068
|
+
digest = str(action_digest or "").strip()
|
|
2069
|
+
if not digest:
|
|
2070
|
+
return None
|
|
2071
|
+
with self._connect() as conn:
|
|
2072
|
+
row = conn.execute(
|
|
2073
|
+
"""SELECT u.action_digest, u.grant_id, u.operation_id,
|
|
2074
|
+
u.consumed_at, g.scope_digest, g.source_request_id,
|
|
2075
|
+
g.granted_by, g.decision_id, g.status AS grant_status,
|
|
2076
|
+
g.created_at AS grant_created_at,
|
|
2077
|
+
g.expires_at AS grant_expires_at,
|
|
2078
|
+
g.ttl_unbounded AS grant_ttl_unbounded,
|
|
2079
|
+
g.uses_unbounded AS grant_uses_unbounded,
|
|
2080
|
+
g.max_uses, g.uses
|
|
2081
|
+
FROM bounded_grant_uses AS u
|
|
2082
|
+
JOIN bounded_grants AS g ON g.grant_id = u.grant_id
|
|
2083
|
+
WHERE u.action_digest = ?""",
|
|
2084
|
+
(digest,),
|
|
2085
|
+
).fetchone()
|
|
2086
|
+
return self._grant_use_dict(row) if row is not None else None
|
|
2087
|
+
|
|
2088
|
+
def revoke_grant(self, grant_id: str, *, now: Optional[datetime] = None) -> bool:
|
|
2089
|
+
with self._connect() as conn:
|
|
2090
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
2091
|
+
cursor = conn.execute(
|
|
2092
|
+
"UPDATE bounded_grants SET status='revoked', revoked_at=? "
|
|
2093
|
+
"WHERE grant_id=? AND status IN ('active', 'exhausted')",
|
|
2094
|
+
(_iso(_as_utc(now)), grant_id),
|
|
2095
|
+
)
|
|
2096
|
+
changed = cursor.rowcount > 0
|
|
2097
|
+
conn.commit()
|
|
2098
|
+
return changed
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
def authority_mode() -> str:
|
|
2102
|
+
"""Return strict approval authority mode, preserving invalid config."""
|
|
2103
|
+
|
|
2104
|
+
value = os.environ.get("COLONY_APPROVAL_AUTHORITY_MODE", "shadow").strip().lower()
|
|
2105
|
+
return value if value in {"shadow", "enforce"} else "invalid"
|