@@ -48,13 +48,16 @@ def __init__(self, connect, *, base_path: str = '/v2/artifacts') -> None:
4848 self ._connect = connect
4949 self .base_path = base_path
5050 self .counters = {'put' : 0 , 'put_deduplicated' : 0 , 'read' : 0 , 'miss' : 0 ,
51- 'digest_mismatch' : 0 , 'unavailable' : 0 }
51+ 'digest_mismatch' : 0 , 'unavailable' : 0 ,
52+ 'authorization_denied' : 0 ,
53+ 'authorization_schema_unavailable' : 0 }
5254
5355 # ── writes ────────────────────────────────────────────────────────────────
5456 def put (self , data : bytes , * , role : str , media_type : str | None = None ,
5557 metadata : dict [str , Any ] | None = None ,
5658 method_version : str | None = None ,
57- method_id : str | None = None ) -> A .Artifact :
59+ method_id : str | None = None ,
60+ cursor = None ) -> A .Artifact :
5861 """Store bytes and name their role. Idempotent by (content, role).
5962
6063 The digest is computed HERE and passed as the primary key, and the database
@@ -74,7 +77,8 @@ def put(self, data: bytes, *, role: str, media_type: str | None = None,
7477 digest = A .sha256_hex (data )
7578 mt = media_type or A .MEDIA_TYPES .get (role , 'application/octet-stream' )
7679 self .counters ['put' ] += 1
77- with self ._connect () as conn , conn .cursor () as cur :
80+
81+ def persist (cur ) -> A .Artifact :
7882 cur .execute (
7983 'INSERT INTO app.blob (sha256, media_type, byte_len, bytes) '
8084 'VALUES (decode(%s, %s), %s, %s, %s) ON CONFLICT (sha256) DO NOTHING' ,
@@ -111,8 +115,17 @@ def put(self, data: bytes, *, role: str, media_type: str | None = None,
111115 metadata = metadata or {},
112116 method_version = method_version )
113117
118+ # A caller that already owns the authoritative transaction may lend its
119+ # cursor. This is not a convenience: campaign preparation has one CAS
120+ # commit barrier, and opening a private connection here used to make the
121+ # artifact survive when that later CAS rolled back.
122+ if cursor is not None :
123+ return persist (cursor )
124+ with self ._connect () as conn , conn .cursor () as cur :
125+ return persist (cur )
126+
114127 def link_to_job (self , job_id : str , artifact_id : str , role : str ,
115- ordinal : int = 0 ) -> None :
128+ ordinal : int = 0 , * , cursor = None ) -> None :
116129 """Record which invocation produced this artifact.
117130
118131 Separate from `put` because an artifact can exist before its job row does
@@ -122,12 +135,43 @@ def link_to_job(self, job_id: str, artifact_id: str, role: str,
122135 """
123136 if psycopg is None :
124137 return
125- with self ._connect () as conn , conn .cursor () as cur :
138+
139+ def link (cur ) -> None :
126140 cur .execute (
127141 'INSERT INTO app.job_artifact (job_id, artifact_id, role, ordinal) '
128142 'VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING' ,
129143 (job_id , artifact_id , role , ordinal ))
130144
145+ if cursor is not None :
146+ link (cursor )
147+ return
148+ with self ._connect () as conn , conn .cursor () as cur :
149+ link (cur )
150+
151+ def link_to_campaign (self , campaign_id : str , artifact_id : str , role : str ,
152+ ordinal : int = 0 , * , cursor = None ) -> None :
153+ """Make campaign ownership explicit and independently queryable.
154+
155+ Artifact metadata is immutable first-writer context, not an ownership
156+ relation: content deduplication can reuse one artifact in several campaigns.
157+ The join table is therefore the authorization/provenance fact.
158+ """
159+ if psycopg is None :
160+ return
161+
162+ def link (cur ) -> None :
163+ cur .execute (
164+ 'INSERT INTO app.rbfe_campaign_artifact '
165+ '(campaign_id, artifact_id, role, ordinal) '
166+ 'VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING' ,
167+ (campaign_id , artifact_id , role , ordinal ))
168+
169+ if cursor is not None :
170+ link (cursor )
171+ return
172+ with self ._connect () as conn , conn .cursor () as cur :
173+ link (cur )
174+
131175 # ── reads ─────────────────────────────────────────────────────────────────
132176 _SELECT = (
133177 'SELECT a.id, encode(a.blob_sha256, %s), a.role, a.media_type, '
@@ -144,6 +188,105 @@ def _row_to_artifact(self, row) -> A.Artifact:
144188 size_bytes = row [4 ], encoding = row [5 ], id = str (row [0 ]),
145189 metadata = dict (row [6 ] or {}), method_version = row [7 ])
146190
191+ @staticmethod
192+ def _principal (actor : dict [str , str ] | None ) -> tuple [str , str ]:
193+ if not isinstance (actor , dict ):
194+ raise failures .DiracInvalidParameters (
195+ 'artifact access requires an authenticated actor' )
196+ kind = str (actor .get ('kind' ) or '' )
197+ actor_id = str (actor .get ('id' ) or '' ).strip ()
198+ if kind not in {'human' , 'agent' , 'service' } or not actor_id :
199+ raise failures .DiracInvalidParameters (
200+ 'artifact actor must be a human, agent, or service' )
201+ return kind , actor_id
202+
203+ def _require_authorization_schema (self , cur ) -> None :
204+ """Refuse before compiling ACL SQL against a partially migrated schema.
205+
206+ PostgreSQL resolves every relation in a statement before evaluating its
207+ WHERE clause. Without this capability probe, a daemon started before 040
208+ or 045 turns an intentional deny into an undefined-table 500. A legacy
209+ private Artifact is never assigned an inferred owner or made public as a
210+ compatibility shortcut; it remains indistinguishable from absence.
211+ """
212+ cur .execute (
213+ "SELECT to_regclass('app.rbfe_campaign') IS NOT NULL, "
214+ "to_regclass('app.rbfe_campaign_artifact') IS NOT NULL, "
215+ "EXISTS (SELECT 1 FROM pg_constraint "
216+ "WHERE conrelid=to_regclass('app.rbfe_campaign_artifact') "
217+ "AND contype='f' "
218+ "AND conname='rbfe_campaign_artifact_role_fk' "
219+ "AND pg_get_constraintdef(oid) LIKE "
220+ "'%%FOREIGN KEY (artifact_id, role)%%')" )
221+ capability = tuple (cur .fetchone () or (False , False , False ))
222+ if all (capability ):
223+ return
224+ missing = []
225+ if not capability [0 ]:
226+ missing .append ('040_rbfe_campaign_state.sql' )
227+ if not all (capability [1 :3 ]):
228+ missing .append ('045_rbfe_campaign_artifact_ownership.sql' )
229+ self .counters ['authorization_schema_unavailable' ] += 1
230+ raise failures .DiracFailure (
231+ 'DB_UNAVAILABLE' ,
232+ 'artifact authorization schema is incomplete; private artifacts '
233+ 'remain fail-closed until explicit ownership can be verified' ,
234+ details = {
235+ 'required_migrations' : missing ,
236+ 'legacy_unowned_policy' : 'fail_closed' ,
237+ 'owner_inference' : False ,
238+ 'implicit_public' : False ,
239+ })
240+
241+ @staticmethod
242+ def _access_clause (alias : str = 'a' ) -> str :
243+ """SQL ownership witness for an HTTP-visible Artifact.
244+
245+ A UUID or digest is an integrity capability, never an authorization
246+ capability. Legacy/unlinked rows therefore fail closed. Internal
247+ science paths continue to use ``head/read`` because their authorization
248+ happened when the server resolved the owning Job/Campaign reference.
249+ """
250+ return f'''(
251+ coalesce({ alias } .metadata->>'visibility','') = 'public'
252+ OR EXISTS (
253+ SELECT 1 FROM app.job_artifact ja
254+ JOIN app.job j ON j.id=ja.job_id
255+ WHERE ja.artifact_id={ alias } .id
256+ AND j.actor_kind=%s AND j.actor_id=%s
257+ )
258+ OR EXISTS (
259+ SELECT 1 FROM app.rbfe_campaign c
260+ WHERE c.id::text={ alias } .metadata->>'campaign_id'
261+ AND c.created_by_kind=%s AND c.created_by_id=%s
262+ )
263+ OR EXISTS (
264+ SELECT 1 FROM app.rbfe_campaign_artifact ca
265+ JOIN app.rbfe_campaign c ON c.id=ca.campaign_id
266+ WHERE ca.artifact_id={ alias } .id
267+ AND c.created_by_kind=%s AND c.created_by_id=%s
268+ )
269+ OR EXISTS (
270+ SELECT 1
271+ FROM design.motif_scientific_object o
272+ JOIN app.rbfe_campaign c ON EXISTS (
273+ SELECT 1
274+ FROM jsonb_array_elements(
275+ CASE WHEN jsonb_typeof(c.state->'owned_object_refs')='array'
276+ THEN c.state->'owned_object_refs' ELSE '[]'::jsonb END
277+ ) owned_ref
278+ WHERE owned_ref->>'id'=o.id::text
279+ )
280+ WHERE o.document_artifact_id={ alias } .id
281+ AND c.created_by_kind=%s AND c.created_by_id=%s
282+ )
283+ )'''
284+
285+ @staticmethod
286+ def _access_params (kind : str , actor_id : str ) -> tuple [str , ...]:
287+ return (kind , actor_id , kind , actor_id , kind , actor_id ,
288+ kind , actor_id )
289+
147290 def head (self , address : str ) -> A .Artifact :
148291 """Metadata without bytes — the request a client makes to decide.
149292
@@ -184,6 +327,56 @@ def head(self, address: str) -> A.Artifact:
184327 'roles_at_digest' : n }})
185328 return art
186329
330+ def head_authorized (self , address : str ,
331+ actor : dict [str , str ]) -> A .Artifact :
332+ """Resolve metadata only when the authenticated actor owns its lineage.
333+
334+ Digest lookup is filtered before ordering; otherwise the newest artifact
335+ at shared bytes could belong to another actor and make an owned role look
336+ absent (or, worse, disclose which role was newest).
337+ """
338+ if psycopg is None :
339+ raise failures .DiracFailure ('DB_UNAVAILABLE' , 'no database driver' )
340+ kind , actor_id = self ._principal (actor )
341+ by_digest = A .is_digest (address ) or address .startswith ('sha256:' )
342+ digest = address [7 :] if address .startswith ('sha256:' ) else address
343+ access = self ._access_clause ('a' )
344+ access_params = self ._access_params (kind , actor_id )
345+ with self ._connect () as conn , conn .cursor () as cur :
346+ self ._require_authorization_schema (cur )
347+ if by_digest :
348+ cur .execute (
349+ self ._SELECT
350+ + ' WHERE a.blob_sha256=decode(%s,%s) AND ' + access
351+ + ' ORDER BY a.created_at DESC LIMIT 1' ,
352+ ("hex" , digest , "hex" , * access_params ))
353+ else :
354+ cur .execute (
355+ self ._SELECT + ' WHERE a.id=%s AND ' + access ,
356+ ("hex" , address , * access_params ))
357+ row = cur .fetchone ()
358+ if row is None :
359+ self .counters ['miss' ] += 1
360+ self .counters ['authorization_denied' ] += 1
361+ # Deliberately indistinguishable from a nonexistent address.
362+ raise failures .DiracNotFound (
363+ f'no artifact at { address !r} ' ,
364+ details = {'address' : address , 'looked_up_by' :
365+ 'digest' if by_digest else 'id' })
366+ art = self ._row_to_artifact (row )
367+ if by_digest :
368+ cur .execute (
369+ 'SELECT count(*) FROM app.artifact a '
370+ 'WHERE a.blob_sha256=decode(%s,%s) AND ' + access ,
371+ (digest , 'hex' , * access_params ))
372+ count = int (cur .fetchone ()[0 ])
373+ if count > 1 :
374+ art = A .Artifact (** {
375+ ** art .__dict__ ,
376+ 'metadata' : {** art .metadata , 'roles_at_digest' : count },
377+ })
378+ return art
379+
187380 def read (self , address : str ) -> tuple [A .Artifact , bytes ]:
188381 """Bytes, verified against the digest they were stored under.
189382
@@ -213,6 +406,25 @@ def read(self, address: str) -> tuple[A.Artifact, bytes]:
213406 self .counters ['read' ] += 1
214407 return art , data
215408
409+ def read_authorized (self , address : str ,
410+ actor : dict [str , str ]) -> tuple [A .Artifact , bytes ]:
411+ art = self .head_authorized (address , actor )
412+ with self ._connect () as conn , conn .cursor () as cur :
413+ cur .execute ('SELECT bytes FROM app.blob WHERE sha256 = decode(%s, %s)' ,
414+ (art .sha256 , 'hex' ))
415+ row = cur .fetchone ()
416+ if row is None :
417+ self .counters ['miss' ] += 1
418+ raise failures .DiracNotFound (f'no artifact at { address !r} ' )
419+ data = bytes (row [0 ])
420+ try :
421+ A .verify_bytes (data , art .sha256 )
422+ except failures .DiracFailure :
423+ self .counters ['digest_mismatch' ] += 1
424+ raise
425+ self .counters ['read' ] += 1
426+ return art , data
427+
216428 def read_range (self , address : str , range_header : str | None
217429 ) -> tuple [A .Artifact , bytes , tuple [int , int ] | None ]:
218430 """A byte range, sliced AFTER the whole object is verified.
@@ -229,3 +441,13 @@ def read_range(self, address: str, range_header: str | None
229441 return art , data , None
230442 lo , hi = rng
231443 return art , data [lo :hi + 1 ], rng
444+
445+ def read_range_authorized (
446+ self , address : str , range_header : str | None ,
447+ actor : dict [str , str ]) -> tuple [A .Artifact , bytes , tuple [int , int ] | None ]:
448+ art , data = self .read_authorized (address , actor )
449+ rng = A .parse_range (range_header , len (data ))
450+ if rng is None :
451+ return art , data , None
452+ lo , hi = rng
453+ return art , data [lo :hi + 1 ], rng
0 commit comments