vinctor-core 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. vinctor_core/__init__.py +44 -0
  2. vinctor_core/audit.py +126 -0
  3. vinctor_core/enforce.py +119 -0
  4. vinctor_core/infer.py +124 -0
  5. vinctor_core/models.py +179 -0
  6. vinctor_core/policy.py +89 -0
  7. vinctor_core/py.typed +1 -0
  8. vinctor_core/registry.py +113 -0
  9. vinctor_core/scope.py +94 -0
  10. vinctor_core-0.2.0.dist-info/METADATA +771 -0
  11. vinctor_core-0.2.0.dist-info/RECORD +54 -0
  12. vinctor_core-0.2.0.dist-info/WHEEL +4 -0
  13. vinctor_core-0.2.0.dist-info/entry_points.txt +3 -0
  14. vinctor_core-0.2.0.dist-info/licenses/LICENSE +21 -0
  15. vinctor_mcp_server/__init__.py +2 -0
  16. vinctor_mcp_server/__main__.py +6 -0
  17. vinctor_mcp_server/config.py +53 -0
  18. vinctor_mcp_server/output_policy.py +104 -0
  19. vinctor_mcp_server/server.py +84 -0
  20. vinctor_mcp_server/service_client.py +200 -0
  21. vinctor_mcp_server/tools.py +576 -0
  22. vinctor_service/__init__.py +196 -0
  23. vinctor_service/__main__.py +6 -0
  24. vinctor_service/audit.py +148 -0
  25. vinctor_service/audit_http.py +179 -0
  26. vinctor_service/authorize.py +48 -0
  27. vinctor_service/auto_approval.py +208 -0
  28. vinctor_service/auto_approval_http.py +239 -0
  29. vinctor_service/boundary_http.py +230 -0
  30. vinctor_service/cli.py +2474 -0
  31. vinctor_service/grant_http.py +280 -0
  32. vinctor_service/grant_request_http.py +660 -0
  33. vinctor_service/grant_requests.py +299 -0
  34. vinctor_service/grants.py +327 -0
  35. vinctor_service/in_memory.py +449 -0
  36. vinctor_service/key_ops.py +121 -0
  37. vinctor_service/keys.py +426 -0
  38. vinctor_service/local_admin.py +478 -0
  39. vinctor_service/local_http.py +711 -0
  40. vinctor_service/local_launcher.py +445 -0
  41. vinctor_service/metrics.py +32 -0
  42. vinctor_service/models.py +208 -0
  43. vinctor_service/policy_files.py +342 -0
  44. vinctor_service/policy_infer.py +90 -0
  45. vinctor_service/pop.py +113 -0
  46. vinctor_service/ratelimit.py +73 -0
  47. vinctor_service/repositories.py +294 -0
  48. vinctor_service/service_config.py +92 -0
  49. vinctor_service/service_runtime.py +112 -0
  50. vinctor_service/sqlite.py +1768 -0
  51. vinctor_service/storage_ops.py +150 -0
  52. vinctor_service/subject_tokens.py +115 -0
  53. vinctor_service/v1_enforce.py +534 -0
  54. vinctor_service/v1_http.py +367 -0
@@ -0,0 +1,44 @@
1
+ """Vinctor deterministic authorization core."""
2
+
3
+ from vinctor_core.audit import AuditEventInput, build_audit_event
4
+ from vinctor_core.enforce import evaluate_enforce
5
+ from vinctor_core.models import (
6
+ AuditEvent,
7
+ Boundary,
8
+ BoundaryRegistrationInput,
9
+ DecisionResult,
10
+ EnforceInput,
11
+ Grant,
12
+ PolicyInput,
13
+ PolicyResult,
14
+ )
15
+ from vinctor_core.policy import evaluate_policy
16
+ from vinctor_core.registry import (
17
+ BoundaryRegistry,
18
+ disable_boundary,
19
+ enable_boundary,
20
+ get_boundary_for_workspace,
21
+ register_boundary,
22
+ )
23
+ from vinctor_core.scope import scope_subsumes
24
+
25
+ __all__ = [
26
+ "AuditEvent",
27
+ "AuditEventInput",
28
+ "Boundary",
29
+ "BoundaryRegistrationInput",
30
+ "BoundaryRegistry",
31
+ "DecisionResult",
32
+ "EnforceInput",
33
+ "Grant",
34
+ "PolicyInput",
35
+ "PolicyResult",
36
+ "build_audit_event",
37
+ "disable_boundary",
38
+ "enable_boundary",
39
+ "evaluate_enforce",
40
+ "evaluate_policy",
41
+ "get_boundary_for_workspace",
42
+ "register_boundary",
43
+ "scope_subsumes",
44
+ ]
vinctor_core/audit.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, datetime
5
+ from secrets import token_urlsafe
6
+
7
+ from vinctor_core.models import AuditEvent, DecisionResult
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class AuditEventInput:
12
+ decision: DecisionResult
13
+ event_id: str | None = None
14
+ created_at: datetime | None = None
15
+ enforcing_principal: str | None = None
16
+ identity_proven: bool = False
17
+ token_id: str | None = None
18
+
19
+
20
+ def build_audit_event(audit_input: AuditEventInput) -> AuditEvent:
21
+ decision = audit_input.decision
22
+ boundary = decision.boundary
23
+ boundary_id = boundary.boundary_id if boundary else decision.attempted_boundary_id
24
+
25
+ return AuditEvent(
26
+ event_id=audit_input.event_id or _new_event_id(),
27
+ event_type="action_permitted" if decision.decision == "permit" else "action_denied",
28
+ decision=decision.decision,
29
+ reason=decision.reason,
30
+ workspace_id=decision.workspace_id,
31
+ agent_id=decision.agent_id,
32
+ grant_id=decision.grant_id,
33
+ grant_ref=decision.grant_ref,
34
+ action=decision.action,
35
+ resource=decision.resource,
36
+ scope_attempted=decision.scope_attempted,
37
+ scope_matched=decision.scope_matched,
38
+ boundary_id=boundary_id,
39
+ runtime=boundary.runtime if boundary else None,
40
+ boundary_type=boundary.boundary_type if boundary else None,
41
+ created_at=audit_input.created_at or datetime.now(UTC),
42
+ enforcing_principal=audit_input.enforcing_principal,
43
+ identity_proven=audit_input.identity_proven,
44
+ token_id=audit_input.token_id,
45
+ )
46
+
47
+
48
+ # Operator-only audit for security-relevant pre-grant-evaluation rejections
49
+ # (ADR 0008). These never alter the caller-facing response.
50
+ EVENT_ACCESS_REJECTED = "access_rejected"
51
+ EVENT_AUTH_FAILED = "auth_failed"
52
+ EVENT_GRANT_ISSUE_REJECTED = "grant_issue_rejected"
53
+ EVENT_SUBJECT_TOKEN_MINTED = "subject_token_minted"
54
+
55
+ # Coarse `reason_code` enum for rejection events. Intentionally low-cardinality
56
+ # so the audit trail carries the security signal without leaking specifics.
57
+ REASON_AGENT_GRANT_MISMATCH = "agent_grant_mismatch"
58
+ REASON_AUTH_FAILED = "auth_failed"
59
+ REASON_SCOPE_OUTSIDE_ISSUABLE_BOUNDS = "scope_outside_issuable_bounds"
60
+ REASON_ISSUABLE_BOUNDS_NOT_FOUND = "issuable_bounds_not_found"
61
+ REASON_TTL_EXCEEDS_ISSUABLE_MAX = "ttl_exceeds_issuable_max"
62
+ REASON_SUBJECT_TOKEN_INVALID = "subject_token_invalid"
63
+ REASON_SUBJECT_TOKEN_REQUIRED = "subject_token_required"
64
+ REASON_POP_REQUIRED = "pop_required"
65
+
66
+
67
+ def build_rejection_audit_event(
68
+ *,
69
+ reason_code: str,
70
+ workspace_id: str,
71
+ agent_id: str,
72
+ created_at: datetime,
73
+ event_type: str = EVENT_ACCESS_REJECTED,
74
+ action: str = "",
75
+ resource: str = "",
76
+ scope_attempted: str | None = None,
77
+ boundary_id: str | None = None,
78
+ enforcing_principal: str | None = None,
79
+ event_id: str | None = None,
80
+ occurrence_count: int | None = None,
81
+ first_seen_at: datetime | None = None,
82
+ last_seen_at: datetime | None = None,
83
+ ) -> AuditEvent:
84
+ """Build an audit event for a request rejected BEFORE grant-scope evaluation.
85
+
86
+ Per ADR 0008, security-relevant pre-grant rejections (e.g. an agent naming a
87
+ grant that is not its own, or an operator over-issuing beyond an agent's
88
+ bounds) are recorded for the operator, while the caller-facing response stays
89
+ generic and leak-free. The event carries a coarse ``reason_code`` (also
90
+ mirrored into ``reason`` so existing reason-keyed audit columns/queries keep
91
+ working) and deliberately discloses no grant identifiers:
92
+ ``grant_id``/``grant_ref`` are empty so the offending grant is never revealed
93
+ in the trail. ``action``/``resource`` are retained as operator-only audit
94
+ signal (they are not a caller leak). ``scope_attempted`` defaults to
95
+ ``action:resource`` (the enforce case) but can be set explicitly (e.g. the
96
+ requested scopes of an issuance). The authentication-failure path additionally
97
+ carries an aggregated ``occurrence_count`` with the window's first/last-seen
98
+ timestamps.
99
+ """
100
+ return AuditEvent(
101
+ event_id=event_id or _new_event_id(),
102
+ event_type=event_type,
103
+ decision="deny",
104
+ reason=reason_code,
105
+ workspace_id=workspace_id,
106
+ agent_id=agent_id,
107
+ grant_id="",
108
+ grant_ref="",
109
+ action=action,
110
+ resource=resource,
111
+ scope_attempted=scope_attempted if scope_attempted is not None else f"{action}:{resource}",
112
+ scope_matched=None,
113
+ boundary_id=boundary_id,
114
+ runtime=None,
115
+ boundary_type=None,
116
+ created_at=created_at,
117
+ enforcing_principal=enforcing_principal,
118
+ reason_code=reason_code,
119
+ occurrence_count=occurrence_count,
120
+ first_seen_at=first_seen_at,
121
+ last_seen_at=last_seen_at,
122
+ )
123
+
124
+
125
+ def _new_event_id() -> str:
126
+ return f"evt_{token_urlsafe(12)}"
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from vinctor_core.models import Boundary, DecisionResult, EnforceInput
4
+ from vinctor_core.scope import (
5
+ attempted_scope,
6
+ is_valid_grant_scope,
7
+ is_valid_requested_action,
8
+ is_valid_requested_resource,
9
+ match_scope,
10
+ )
11
+
12
+
13
+ def evaluate_enforce(enforce_input: EnforceInput) -> DecisionResult:
14
+ grant = enforce_input.grant
15
+ scope_attempted = attempted_scope(enforce_input.action, enforce_input.resource)
16
+
17
+ boundary_result = _resolve_boundary(enforce_input)
18
+ if isinstance(boundary_result, DecisionResult):
19
+ return boundary_result
20
+ boundary = boundary_result
21
+
22
+ if not is_valid_requested_action(enforce_input.action):
23
+ return _deny(enforce_input, "invalid_action", scope_attempted, boundary=boundary)
24
+ if not is_valid_requested_resource(enforce_input.resource):
25
+ return _deny(enforce_input, "invalid_resource", scope_attempted, boundary=boundary)
26
+
27
+ if grant.status == "revoked":
28
+ return _deny(enforce_input, "grant_revoked", scope_attempted, boundary=boundary)
29
+ if grant.status == "expired":
30
+ return _deny(enforce_input, "grant_expired", scope_attempted, boundary=boundary)
31
+ if grant.status != "active":
32
+ return _deny(enforce_input, "grant_not_active", scope_attempted, boundary=boundary)
33
+ if grant.expires_at is not None and grant.expires_at <= enforce_input.now:
34
+ return _deny(enforce_input, "grant_expired", scope_attempted, boundary=boundary)
35
+ if any(not is_valid_grant_scope(scope) for scope in grant.scopes):
36
+ return _deny(enforce_input, "invalid_grant_scope", scope_attempted, boundary=boundary)
37
+
38
+ matched = match_scope(grant.scopes, enforce_input.action, enforce_input.resource)
39
+ if matched is None:
40
+ return _deny(enforce_input, "action_denied", scope_attempted, boundary=boundary)
41
+
42
+ return DecisionResult(
43
+ decision="permit",
44
+ reason="permitted",
45
+ grant_id=grant.grant_id,
46
+ grant_ref=grant.grant_ref,
47
+ workspace_id=grant.workspace_id,
48
+ agent_id=grant.agent_id,
49
+ action=enforce_input.action,
50
+ resource=enforce_input.resource,
51
+ scope_attempted=scope_attempted,
52
+ scope_matched=matched,
53
+ boundary=boundary,
54
+ attempted_boundary_id=enforce_input.boundary_id,
55
+ )
56
+
57
+
58
+ def _resolve_boundary(enforce_input: EnforceInput) -> Boundary | DecisionResult | None:
59
+ boundary_id = enforce_input.boundary_id
60
+ if boundary_id is None:
61
+ if enforce_input.require_boundary:
62
+ return _deny(
63
+ enforce_input,
64
+ "boundary_required",
65
+ attempted_scope(enforce_input.action, enforce_input.resource),
66
+ )
67
+ return None
68
+
69
+ registry = enforce_input.boundary_registry
70
+ boundary = registry.get(boundary_id) if registry is not None else None
71
+ scope_attempted = attempted_scope(enforce_input.action, enforce_input.resource)
72
+ if boundary is None:
73
+ return _deny(
74
+ enforce_input,
75
+ "boundary_not_found",
76
+ scope_attempted,
77
+ attempted_boundary_id=boundary_id,
78
+ )
79
+ if boundary.workspace_id != enforce_input.grant.workspace_id:
80
+ return _deny(
81
+ enforce_input,
82
+ "boundary_wrong_workspace",
83
+ scope_attempted,
84
+ attempted_boundary_id=boundary_id,
85
+ )
86
+ if boundary.status != "active":
87
+ return _deny(
88
+ enforce_input,
89
+ "boundary_inactive",
90
+ scope_attempted,
91
+ boundary=boundary,
92
+ attempted_boundary_id=boundary_id,
93
+ )
94
+ return boundary
95
+
96
+
97
+ def _deny(
98
+ enforce_input: EnforceInput,
99
+ reason: str,
100
+ scope_attempted: str,
101
+ *,
102
+ boundary: Boundary | None = None,
103
+ attempted_boundary_id: str | None = None,
104
+ ) -> DecisionResult:
105
+ grant = enforce_input.grant
106
+ return DecisionResult(
107
+ decision="deny",
108
+ reason=reason,
109
+ grant_id=grant.grant_id,
110
+ grant_ref=grant.grant_ref,
111
+ workspace_id=grant.workspace_id,
112
+ agent_id=grant.agent_id,
113
+ action=enforce_input.action,
114
+ resource=enforce_input.resource,
115
+ scope_attempted=scope_attempted,
116
+ scope_matched=None,
117
+ boundary=boundary,
118
+ attempted_boundary_id=attempted_boundary_id or enforce_input.boundary_id,
119
+ )
vinctor_core/infer.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass
6
+
7
+ from vinctor_core.scope import (
8
+ attempted_scope,
9
+ is_valid_grant_scope,
10
+ is_valid_requested_action,
11
+ is_valid_requested_resource,
12
+ )
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Observation:
17
+ """A single observed (action, resource) access, optionally pre-aggregated.
18
+
19
+ `count` is how many times it was seen; `last_seen` is an ISO-8601 timestamp
20
+ string (which sorts chronologically) or None.
21
+ """
22
+
23
+ action: str
24
+ resource: str
25
+ count: int = 1
26
+ last_seen: str | None = None
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ScopeProposal:
31
+ """A proposed grant scope, annotated so an operator can review before applying.
32
+
33
+ `covers` lists the concrete `action:resource` scopes this proposal subsumes
34
+ (itself, for an exact scope; all observed siblings, for a generalized wildcard).
35
+ """
36
+
37
+ scope: str
38
+ count: int
39
+ last_seen: str | None
40
+ generalized: bool
41
+ covers: tuple[str, ...]
42
+
43
+
44
+ def _latest(a: str | None, b: str | None) -> str | None:
45
+ if a is None:
46
+ return b
47
+ if b is None:
48
+ return a
49
+ return max(a, b) # ISO-8601 strings sort chronologically
50
+
51
+
52
+ def propose_scopes(
53
+ observations: Iterable[Observation],
54
+ *,
55
+ generalize: bool = False,
56
+ ) -> tuple[ScopeProposal, ...]:
57
+ """Deterministically propose the narrowest grant scopes covering observations.
58
+
59
+ Exact by default (one scope per distinct valid pair). With `generalize`, a group
60
+ of >=2 sibling resources under a parent of >=2 segments collapses to a single
61
+ terminal wildcard `action:parent/*`; shallow parents are deliberately NOT
62
+ widened (avoids top-level `category/*` footguns). Invalid actions/resources are
63
+ dropped. Propose-only — this never applies policy.
64
+ """
65
+ # Aggregate valid (action, resource) observations.
66
+ agg: dict[tuple[str, str], tuple[int, str | None]] = {}
67
+ for obs in observations:
68
+ if not is_valid_requested_action(obs.action):
69
+ continue
70
+ if not is_valid_requested_resource(obs.resource):
71
+ continue
72
+ key = (obs.action, obs.resource)
73
+ count, last_seen = agg.get(key, (0, None))
74
+ agg[key] = (count + obs.count, _latest(last_seen, obs.last_seen))
75
+
76
+ def _exact(action: str, resource: str, count: int, last_seen: str | None) -> ScopeProposal:
77
+ scope = attempted_scope(action, resource)
78
+ return ScopeProposal(
79
+ scope=scope,
80
+ count=count,
81
+ last_seen=last_seen,
82
+ generalized=False,
83
+ covers=(scope,),
84
+ )
85
+
86
+ if not generalize:
87
+ proposals = [
88
+ _exact(action, resource, count, last_seen)
89
+ for (action, resource), (count, last_seen) in agg.items()
90
+ ]
91
+ return tuple(sorted(proposals, key=lambda p: p.scope))
92
+
93
+ # Group by (action, parent) for generalization.
94
+ groups: dict[tuple[str, str], list[tuple[str, int, str | None]]] = defaultdict(list)
95
+ for (action, resource), (count, last_seen) in agg.items():
96
+ parent = resource.rsplit("/", 1)[0]
97
+ groups[(action, parent)].append((resource, count, last_seen))
98
+
99
+ proposals = []
100
+ for (action, parent), members in groups.items():
101
+ wildcard = f"{action}:{parent}/*"
102
+ deep_enough = len(parent.split("/")) >= 2
103
+ if len(members) >= 2 and deep_enough and is_valid_grant_scope(wildcard):
104
+ total = sum(count for _, count, _ in members)
105
+ latest: str | None = None
106
+ for _, _, last_seen in members:
107
+ latest = _latest(latest, last_seen)
108
+ covers = tuple(sorted(attempted_scope(action, resource) for resource, _, _ in members))
109
+ proposals.append(
110
+ ScopeProposal(
111
+ scope=wildcard,
112
+ count=total,
113
+ last_seen=latest,
114
+ generalized=True,
115
+ covers=covers,
116
+ )
117
+ )
118
+ else:
119
+ proposals.extend(
120
+ _exact(action, resource, count, last_seen)
121
+ for resource, count, last_seen in members
122
+ )
123
+
124
+ return tuple(sorted(proposals, key=lambda p: p.scope))
vinctor_core/models.py ADDED
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Literal, Protocol
6
+
7
+ Decision = Literal["permit", "deny"]
8
+ BoundaryMode = Literal["fail_closed"]
9
+ BoundaryStatus = Literal["active", "disabled"]
10
+
11
+
12
+ @dataclass
13
+ class Grant:
14
+ grant_id: str
15
+ grant_ref: str
16
+ workspace_id: str
17
+ agent_id: str
18
+ scopes: tuple[str, ...]
19
+ status: str
20
+ expires_at: datetime | None = None
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Boundary:
25
+ boundary_id: str
26
+ workspace_id: str
27
+ name: str
28
+ runtime: str
29
+ boundary_type: str
30
+ mode: BoundaryMode
31
+ status: BoundaryStatus
32
+ created_at: datetime
33
+ updated_at: datetime
34
+
35
+ def with_status(self, status: BoundaryStatus, *, updated_at: datetime) -> Boundary:
36
+ return Boundary(
37
+ boundary_id=self.boundary_id,
38
+ workspace_id=self.workspace_id,
39
+ name=self.name,
40
+ runtime=self.runtime,
41
+ boundary_type=self.boundary_type,
42
+ mode=self.mode,
43
+ status=status,
44
+ created_at=self.created_at,
45
+ updated_at=updated_at,
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class BoundaryRegistrationInput:
51
+ workspace_id: str
52
+ name: str
53
+ runtime: str
54
+ boundary_type: str
55
+ mode: BoundaryMode = "fail_closed"
56
+ status: BoundaryStatus = "active"
57
+
58
+
59
+ class BoundaryLookup(Protocol):
60
+ def get(self, boundary_id: str) -> Boundary | None: ...
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class EnforceInput:
65
+ grant: Grant
66
+ action: str
67
+ resource: str
68
+ now: datetime
69
+ boundary_id: str | None = None
70
+ boundary_registry: BoundaryLookup | None = None
71
+ require_boundary: bool = False
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class DecisionResult:
76
+ decision: Decision
77
+ reason: str
78
+ grant_id: str
79
+ grant_ref: str
80
+ workspace_id: str
81
+ agent_id: str
82
+ action: str
83
+ resource: str
84
+ scope_attempted: str
85
+ scope_matched: str | None
86
+ boundary: Boundary | None = None
87
+ attempted_boundary_id: str | None = None
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class PolicyInput:
92
+ workspace_id: str
93
+ agent_id: str
94
+ grants: tuple[Grant, ...]
95
+ action: str
96
+ resource: str
97
+ now: datetime
98
+ boundary_id: str | None = None
99
+ boundary_registry: BoundaryLookup | None = None
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class PolicyResult:
104
+ decision: Decision
105
+ reason: str
106
+ workspace_id: str
107
+ agent_id: str
108
+ action: str
109
+ resource: str
110
+ scope_attempted: str
111
+ scope_matched: str | None
112
+ grant_id: str | None = None
113
+ grant_ref: str | None = None
114
+ enforce_result: DecisionResult | None = None
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class AuditEvent:
119
+ event_id: str
120
+ event_type: str
121
+ decision: Decision
122
+ reason: str
123
+ workspace_id: str
124
+ agent_id: str
125
+ grant_id: str
126
+ grant_ref: str
127
+ action: str
128
+ resource: str
129
+ scope_attempted: str
130
+ scope_matched: str | None
131
+ boundary_id: str | None
132
+ runtime: str | None
133
+ boundary_type: str | None
134
+ created_at: datetime
135
+ enforcing_principal: str | None = None
136
+ # Pre-grant-evaluation rejection fields (ADR 0008). Absent on decision and
137
+ # grant-lifecycle events; set only on operator-only rejection events.
138
+ reason_code: str | None = None
139
+ occurrence_count: int | None = None
140
+ first_seen_at: datetime | None = None
141
+ last_seen_at: datetime | None = None
142
+ # ADR 0007 Model 2 identity-proof (set only on a proven delegated decision).
143
+ identity_proven: bool = False
144
+ token_id: str | None = None
145
+
146
+ def to_dict(self) -> dict[str, object]:
147
+ event: dict[str, object] = {
148
+ "event_id": self.event_id,
149
+ "event_type": self.event_type,
150
+ "decision": self.decision,
151
+ "reason": self.reason,
152
+ "workspace_id": self.workspace_id,
153
+ "agent_id": self.agent_id,
154
+ "grant_id": self.grant_id,
155
+ "grant_ref": self.grant_ref,
156
+ "action": self.action,
157
+ "resource": self.resource,
158
+ "scope_attempted": self.scope_attempted,
159
+ "scope_matched": self.scope_matched,
160
+ "boundary_id": self.boundary_id,
161
+ "runtime": self.runtime,
162
+ "boundary_type": self.boundary_type,
163
+ "created_at": self.created_at.isoformat(),
164
+ }
165
+ if self.enforcing_principal is not None:
166
+ event["enforcing_principal"] = self.enforcing_principal
167
+ if self.reason_code is not None:
168
+ event["reason_code"] = self.reason_code
169
+ if self.occurrence_count is not None:
170
+ event["occurrence_count"] = self.occurrence_count
171
+ if self.first_seen_at is not None:
172
+ event["first_seen_at"] = self.first_seen_at.isoformat()
173
+ if self.last_seen_at is not None:
174
+ event["last_seen_at"] = self.last_seen_at.isoformat()
175
+ if self.identity_proven:
176
+ event["identity_proven"] = True
177
+ if self.token_id is not None:
178
+ event["token_id"] = self.token_id
179
+ return event
vinctor_core/policy.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+
5
+ from vinctor_core.enforce import evaluate_enforce
6
+ from vinctor_core.models import DecisionResult, EnforceInput, Grant, PolicyInput, PolicyResult
7
+ from vinctor_core.scope import (
8
+ attempted_scope,
9
+ is_valid_requested_action,
10
+ is_valid_requested_resource,
11
+ )
12
+
13
+
14
+ def evaluate_policy(policy_input: PolicyInput) -> PolicyResult:
15
+ scope_attempted = attempted_scope(policy_input.action, policy_input.resource)
16
+
17
+ if not is_valid_requested_action(policy_input.action):
18
+ return _deny(policy_input, "invalid_action", scope_attempted)
19
+ if not is_valid_requested_resource(policy_input.resource):
20
+ return _deny(policy_input, "invalid_resource", scope_attempted)
21
+
22
+ for grant in _candidate_grants(policy_input):
23
+ result = evaluate_enforce(
24
+ EnforceInput(
25
+ grant=grant,
26
+ action=policy_input.action,
27
+ resource=policy_input.resource,
28
+ now=policy_input.now,
29
+ boundary_id=policy_input.boundary_id,
30
+ boundary_registry=policy_input.boundary_registry,
31
+ )
32
+ )
33
+ if result.decision == "permit":
34
+ return _from_enforce_result(policy_input, result)
35
+ if result.reason.startswith("boundary_"):
36
+ return _from_enforce_result(policy_input, result)
37
+ if result.reason in {
38
+ "invalid_action",
39
+ "invalid_resource",
40
+ "invalid_grant_scope",
41
+ }:
42
+ return _from_enforce_result(policy_input, result)
43
+
44
+ return _deny(policy_input, "no_applicable_grant", scope_attempted)
45
+
46
+
47
+ def _candidate_grants(policy_input: PolicyInput) -> Iterator[Grant]:
48
+ return (
49
+ grant
50
+ for grant in policy_input.grants
51
+ if grant.workspace_id == policy_input.workspace_id
52
+ and grant.agent_id == policy_input.agent_id
53
+ )
54
+
55
+
56
+ def _from_enforce_result(
57
+ policy_input: PolicyInput,
58
+ result: DecisionResult,
59
+ ) -> PolicyResult:
60
+ return PolicyResult(
61
+ decision=result.decision,
62
+ reason=result.reason,
63
+ workspace_id=policy_input.workspace_id,
64
+ agent_id=policy_input.agent_id,
65
+ action=policy_input.action,
66
+ resource=policy_input.resource,
67
+ scope_attempted=result.scope_attempted,
68
+ scope_matched=result.scope_matched,
69
+ grant_id=result.grant_id,
70
+ grant_ref=result.grant_ref,
71
+ enforce_result=result,
72
+ )
73
+
74
+
75
+ def _deny(
76
+ policy_input: PolicyInput,
77
+ reason: str,
78
+ scope_attempted: str,
79
+ ) -> PolicyResult:
80
+ return PolicyResult(
81
+ decision="deny",
82
+ reason=reason,
83
+ workspace_id=policy_input.workspace_id,
84
+ agent_id=policy_input.agent_id,
85
+ action=policy_input.action,
86
+ resource=policy_input.resource,
87
+ scope_attempted=scope_attempted,
88
+ scope_matched=None,
89
+ )
vinctor_core/py.typed ADDED
@@ -0,0 +1 @@
1
+