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.
- vinctor_core/__init__.py +44 -0
- vinctor_core/audit.py +126 -0
- vinctor_core/enforce.py +119 -0
- vinctor_core/infer.py +124 -0
- vinctor_core/models.py +179 -0
- vinctor_core/policy.py +89 -0
- vinctor_core/py.typed +1 -0
- vinctor_core/registry.py +113 -0
- vinctor_core/scope.py +94 -0
- vinctor_core-0.2.0.dist-info/METADATA +771 -0
- vinctor_core-0.2.0.dist-info/RECORD +54 -0
- vinctor_core-0.2.0.dist-info/WHEEL +4 -0
- vinctor_core-0.2.0.dist-info/entry_points.txt +3 -0
- vinctor_core-0.2.0.dist-info/licenses/LICENSE +21 -0
- vinctor_mcp_server/__init__.py +2 -0
- vinctor_mcp_server/__main__.py +6 -0
- vinctor_mcp_server/config.py +53 -0
- vinctor_mcp_server/output_policy.py +104 -0
- vinctor_mcp_server/server.py +84 -0
- vinctor_mcp_server/service_client.py +200 -0
- vinctor_mcp_server/tools.py +576 -0
- vinctor_service/__init__.py +196 -0
- vinctor_service/__main__.py +6 -0
- vinctor_service/audit.py +148 -0
- vinctor_service/audit_http.py +179 -0
- vinctor_service/authorize.py +48 -0
- vinctor_service/auto_approval.py +208 -0
- vinctor_service/auto_approval_http.py +239 -0
- vinctor_service/boundary_http.py +230 -0
- vinctor_service/cli.py +2474 -0
- vinctor_service/grant_http.py +280 -0
- vinctor_service/grant_request_http.py +660 -0
- vinctor_service/grant_requests.py +299 -0
- vinctor_service/grants.py +327 -0
- vinctor_service/in_memory.py +449 -0
- vinctor_service/key_ops.py +121 -0
- vinctor_service/keys.py +426 -0
- vinctor_service/local_admin.py +478 -0
- vinctor_service/local_http.py +711 -0
- vinctor_service/local_launcher.py +445 -0
- vinctor_service/metrics.py +32 -0
- vinctor_service/models.py +208 -0
- vinctor_service/policy_files.py +342 -0
- vinctor_service/policy_infer.py +90 -0
- vinctor_service/pop.py +113 -0
- vinctor_service/ratelimit.py +73 -0
- vinctor_service/repositories.py +294 -0
- vinctor_service/service_config.py +92 -0
- vinctor_service/service_runtime.py +112 -0
- vinctor_service/sqlite.py +1768 -0
- vinctor_service/storage_ops.py +150 -0
- vinctor_service/subject_tokens.py +115 -0
- vinctor_service/v1_enforce.py +534 -0
- vinctor_service/v1_http.py +367 -0
vinctor_core/__init__.py
ADDED
|
@@ -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)}"
|
vinctor_core/enforce.py
ADDED
|
@@ -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
|
+
|