Zion Boggan
repos/Oversight/oversight_core/policy.py
zionboggan.com ↗
199 lines · python
History for this file →
1
"""
2
oversight_core.policy
3
====================
4
 
5
Policy enforcement at open time.
6
 
7
The manifest carries a `policy` dict with optional fields:
8
    not_after        : unix seconds; decryption refused after this time
9
    not_before       : unix seconds; decryption refused before this time (defer release)
10
    max_opens        : int; decryption refused after this many successful opens
11
    jurisdiction     : str; required jurisdiction profile (enforced against opener config)
12
    require_attestation : bool; reserved for TEE integration
13
    registry_url     : str; used for open-counter increments
14
 
15
Enforcement modes:
16
    LOCAL_ONLY   : policy_state is read/written in a local file (single-user, stub)
17
    REGISTRY     : policy_state kept in registry; increments require a network roundtrip
18
    HYBRID       : prefer registry; fall back to local if offline (with auditable note)
19
 
20
The LOCAL_ONLY mode is not secure against a determined attacker who tampers with
21
the state file. It exists for MVP plumbing. REGISTRY is the real answer.
22
"""
23
 
24
from __future__ import annotations
25
 
26
import json
27
import os
28
import time
29
from dataclasses import dataclass
30
from pathlib import Path
31
from typing import Optional
32
 
33
from .manifest import Manifest
34
 
35
 
36
class PolicyViolation(Exception):
37
    """Raised when a .sealed file's policy forbids the attempted open."""
38
 
39
 
40
@dataclass
41
class PolicyContext:
42
    """State the opener needs to enforce policy. Typically constructed from env/config."""
43
 
44
    jurisdiction: str = "GLOBAL"
45
    state_dir: Optional[Path] = None
46
    registry_url: Optional[str] = None
47
    mode: str = "LOCAL_ONLY"
48
 
49
    def __post_init__(self):
50
        if self.state_dir:
51
            self.state_dir = Path(self.state_dir)
52
            self.state_dir.mkdir(parents=True, exist_ok=True)
53
 
54
 
55
def _local_counter_path(ctx: PolicyContext, file_id: str) -> Path:
56
    if ctx.state_dir is None:
57
        raise ValueError("PolicyContext.state_dir is required for LOCAL_ONLY mode")
58
    if "/" in file_id or "\\" in file_id or ".." in file_id:
59
        raise ValueError(f"invalid file_id for counter filename: {file_id!r}")
60
    return ctx.state_dir / f"{file_id}.opens.json"
61
 
62
 
63
def _local_read_count(ctx: PolicyContext, file_id: str) -> int:
64
    p = _local_counter_path(ctx, file_id)
65
    if not p.exists():
66
        return 0
67
    try:
68
        return int(json.loads(p.read_text()).get("count", 0))
69
    except (OSError, ValueError, TypeError):
70
        return 0
71
 
72
 
73
def _lock_file(lock_file) -> None:
74
    if os.name == "nt":
75
        import msvcrt
76
 
77
        lock_file.seek(0, os.SEEK_END)
78
        if lock_file.tell() == 0:
79
            lock_file.write("\0")
80
            lock_file.flush()
81
        lock_file.seek(0)
82
        msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
83
        return
84
 
85
    import fcntl
86
 
87
    fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
88
 
89
 
90
def _unlock_file(lock_file) -> None:
91
    if os.name == "nt":
92
        import msvcrt
93
 
94
        lock_file.seek(0)
95
        msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
96
        return
97
 
98
    import fcntl
99
 
100
    fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
101
 
102
 
103
def _local_check_and_bump(ctx: PolicyContext, file_id: str, max_opens: int) -> int:
104
    """
105
    Atomically: check count < max_opens AND bump. Uses an OS file lock
106
    on a sidecar .lock file to serialize concurrent openers of the same file,
107
    plus write-to-temp-then-rename for crash-consistency.
108
    Raises PolicyViolation if max_opens reached.
109
    Returns the new count.
110
    """
111
    import tempfile
112
 
113
    p = _local_counter_path(ctx, file_id)
114
    lock_path = p.with_suffix(".lock")
115
    with open(lock_path, "a+") as lf:
116
        _lock_file(lf)
117
        try:
118
            cur = _local_read_count(ctx, file_id)
119
            if cur >= max_opens:
120
                raise PolicyViolation(
121
                    f"Open limit reached: max_opens={max_opens}, already opened {cur} times"
122
                )
123
            new_count = cur + 1
124
            fd, tmp = tempfile.mkstemp(
125
                prefix=f".{file_id}.opens.",
126
                suffix=".tmp",
127
                dir=str(ctx.state_dir),
128
            )
129
            try:
130
                with os.fdopen(fd, "w") as f:
131
                    json.dump({"count": new_count, "last": int(time.time())}, f)
132
                    f.flush()
133
                    os.fsync(f.fileno())
134
                os.replace(tmp, p)
135
            except Exception:
136
                try:
137
                    os.unlink(tmp)
138
                except OSError:
139
                    pass
140
                raise
141
            return new_count
142
        finally:
143
            _unlock_file(lf)
144
 
145
 
146
def check_policy(manifest: Manifest, ctx: Optional[PolicyContext] = None) -> None:
147
    """
148
    Raise PolicyViolation if the manifest's policy forbids the current open.
149
    Called BEFORE decryption to fail-fast.
150
 
151
    Note: open-counter enforcement is SKIPPED here and done atomically in
152
    record_open to prevent TOCTOU races. check_policy only does cheap
153
    read-only checks (time, jurisdiction).
154
    """
155
    policy = manifest.policy or {}
156
    now = int(time.time())
157
 
158
    na = policy.get("not_after")
159
    if na is not None and now > int(na):
160
        raise PolicyViolation(
161
            f"File expired: not_after={na}, now={now} "
162
            f"({(now - int(na))//3600}h ago)"
163
        )
164
    nb = policy.get("not_before")
165
    if nb is not None and now < int(nb):
166
        raise PolicyViolation(
167
            f"File not yet released: not_before={nb}, now={now} "
168
            f"(available in {(int(nb) - now)//60}m)"
169
        )
170
 
171
    required = policy.get("jurisdiction")
172
    if required and required != "GLOBAL" and ctx is not None:
173
        if required != ctx.jurisdiction:
174
            raise PolicyViolation(
175
                f"Jurisdiction mismatch: file requires '{required}', "
176
                f"opener is in '{ctx.jurisdiction}'"
177
            )
178
 
179
 
180
 
181
def record_open(manifest: Manifest, ctx: Optional[PolicyContext]) -> int:
182
    """
183
    Atomically check-and-bump the open counter (if policy has max_opens).
184
    Raises PolicyViolation if the limit is exceeded. Returns new count.
185
    """
186
    if ctx is None:
187
        return 0
188
    policy = manifest.policy or {}
189
    mx = policy.get("max_opens")
190
    if mx is None:
191
        return 0
192
    if ctx.mode == "LOCAL_ONLY":
193
        return _local_check_and_bump(ctx, manifest.file_id, int(mx))
194
    if ctx.mode in {"REGISTRY", "HYBRID"}:
195
        raise PolicyViolation(
196
            f"{ctx.mode} max_opens enforcement is not implemented; refusing "
197
            "to fall back to LOCAL_ONLY state"
198
        )
199
    raise ValueError(f"unknown policy mode: {ctx.mode!r}")