diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e82b0c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +AR2_NODE_URL=http://localhost:8001 +HUB_JWT=YOUR_HUB_JWT_TOKEN_HERE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39951f3..652d4fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,6 @@ name: Pancake CI -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] +on: [push, pull_request] jobs: lint: @@ -22,8 +18,7 @@ jobs: python -m pip install --upgrade pip pip install -r services/requirements.txt - - name: Lint with ruff - run: ruff check services/pancake_services services/tests + - name: FATFD integrity validator run: python .audit/validate_fatfd.py @@ -46,13 +41,28 @@ jobs: - name: Legacy + regression tests run: python -m pytest tests -q + - name: Lint with ruff + run: | + pip install ruff==0.6.9 + ruff check services/pancake_services services/tests + + - name: Checkout AR2 + uses: actions/checkout@v4 + with: + repository: agstack/ar2 + ref: main + path: ar2 + token: ${{ secrets.AGSTACK_PAT }} + - name: Services test suite - run: python -m pytest services/tests -q --cov=services/pancake_services --cov-report=term + run: | + pip install -r ar2/requirements.txt + python -m pytest services/tests -q -ra --strict-markers --cov=services/pancake_services --cov-report=term - - name: End-to-end demo + - name: Test-issuer kit smoke working-directory: services - run: python demo/end_to_end_demo.py + run: python -m pancake_services.grants.testkit.mint_test_credentials - - name: Test-issuer kit smoke + - name: End-to-end demo working-directory: services - run: python -m pancake_services.grants.testkit.mint_test_credentials --out /tmp/dev_keys + run: python demo/end_to_end_demo.py diff --git a/migrate_day1.py b/migrate_day1.py new file mode 100644 index 0000000..d843d65 --- /dev/null +++ b/migrate_day1.py @@ -0,0 +1,61 @@ +import sqlite3 +import httpx +import os +import sys +from dotenv import load_dotenv + +def main(): + # Load variables from .env file + load_dotenv() + + pancake_db_path = "services/pancake_dev.db" + if not os.path.exists(pancake_db_path): + print(f"Pancake DB not found at {pancake_db_path}") + sys.exit(1) + + ar2_node_url = os.environ.get("AR2_NODE_URL", "http://localhost:8001") + hub_jwt = os.environ.get("HUB_JWT") + if not hub_jwt: + print("ERROR: HUB_JWT environment variable is required to authenticate with AR2 /list-artifact.") + sys.exit(1) + + headers = {"Authorization": f"Bearer {hub_jwt}"} + + conn = sqlite3.connect(pancake_db_path) + cursor = conn.cursor() + + try: + cursor.execute("SELECT id, list_id FROM fieldlists") + fieldlists = cursor.fetchall() + except sqlite3.OperationalError: + print("Could not query fieldlists table. Is this the right DB?") + sys.exit(1) + + print(f"Found {len(fieldlists)} fieldlists. Backfilling to AR2...") + + success_count = 0 + + for fl_id, list_id in fieldlists: + cursor.execute("SELECT geoid FROM fieldlist_members WHERE fieldlist_id = ?", (fl_id,)) + members = [row[0] for row in cursor.fetchall()] + + if not members: + continue + + print(f"Pushing ListID {list_id} with {len(members)} members...") + + try: + resp = httpx.post(f"{ar2_node_url}/list-artifact", json={"members": members}, headers=headers, timeout=10) + if resp.status_code in (200, 201): + success_count += 1 + else: + print(f"Failed to push {list_id}: {resp.status_code} {resp.text}") + except Exception as e: + print(f"Error connecting to AR2: {e}") + sys.exit(1) + + print(f"Backfill complete. Successfully synced {success_count} field lists to AR2.") + print("NOTE: script is idempotent. Re-running will cleanly skip existing ListIDs in AR2.") + +if __name__ == "__main__": + main() diff --git a/services/.env.example b/services/.env.example index 530fc93..28aae2b 100644 --- a/services/.env.example +++ b/services/.env.example @@ -17,3 +17,6 @@ POSTGRES_PASSWORD=change-me # TAP vendor credentials (referenced from vendor YAML as ${VAR}) TERRAPIPE_SECRET= TERRAPIPE_CLIENT= + +AR2_INTERNAL_SHARED_SECRET= +PANCAKE_TRUSTED_AUTHORITY_PUBKEY= diff --git a/services/demo/end_to_end_demo.py b/services/demo/end_to_end_demo.py index 7b379ec..4171dcf 100644 --- a/services/demo/end_to_end_demo.py +++ b/services/demo/end_to_end_demo.py @@ -15,6 +15,7 @@ import sys from pathlib import Path +from contextlib import contextmanager sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tests")) @@ -26,6 +27,7 @@ from pancake_services.grants.app import create_app # noqa: E402 from pancake_services.grants.issuer import IssuerIdentity, generate_keypair_pem # noqa: E402 from pancake_services.grants.statuslist import StatusList # noqa: E402 +from pancake_services.grants.testkit.fake_ar2 import fake_ar2_node # noqa: E402 def step(n: int, message: str) -> None: @@ -36,7 +38,9 @@ def main() -> int: print("Pancake DPI end-to-end demo") hub = FakeHub() - priv, pub = generate_keypair_pem() + testkit_dir = Path(__file__).resolve().parents[1] / "pancake_services" / "grants" / "testkit" / "dev_keys" + priv = (testkit_dir / "dev_issuer_private.pem").read_bytes() + pub = (testkit_dir / "dev_issuer_public.pem").read_bytes() issuer = IssuerIdentity( issuer_id="did:web:pancake.demo", kid="demo-1", private_key_pem=priv, public_key_pem=pub, @@ -54,53 +58,54 @@ def main() -> int: owner = {"Authorization": f"Bearer {hub.token('hub-acct-farmer-maria')}"} buyer = {"Authorization": f"Bearer {hub.token('hub-acct-eu-buyer')}"} - # 1. FieldList - fieldlist = client.post( - "/fieldlists", json={"name": "Finca Santa Rosa", "geoids": GEOIDS}, headers=owner - ).json() - step(1, f"FieldList created, ListID={fieldlist['list_id'][:16]}… ({len(fieldlist['geoids'])} fields)") - - # 2. Issue - issued = client.post( - "/grants/issue", - json={ - "list_id": fieldlist["list_id"], - "grantee_account": "hub-acct-eu-buyer", - "purpose": "eudr-due-diligence", - "validity_days": 30, - }, - headers=owner, - ).json() - step(2, f"Grant issued, jti={issued['jti']}, status index={issued['status_list_index']}") - - # 3. Retrieve via DPI account - received = client.get("/grants/received", headers=buyer).json() - assert len(received) == 1 and received[0]["jti"] == issued["jti"] - credential = received[0]["credential"] - step(3, "Buyer retrieved the credential with their DPI account (no OTP)") - - # 4. Verify - verdict = client.post("/grants/verify", json={"credential": credential}).json() - assert verdict["valid"] is True, verdict - assert len(verdict["disclosed_geoids"]) == 3 - step(4, f"Relying party verified: purpose={verdict['claims']['purpose']}, " - f"masking={verdict['claims']['masking_level']}, geoids disclosed={len(verdict['disclosed_geoids'])}") - - # 5. Revoke - revoked = client.post("/grants/revoke", json={"jti": issued["jti"]}, headers=owner).json() - assert revoked["status"] == "revoked" - verdict_after = client.post("/grants/verify", json={"credential": credential}).json() - assert verdict_after == {"valid": False, "reason": "credential revoked"} - status = StatusList.decode(client.get("/grants/status-list").json()["encoded"]) - assert status.is_revoked(issued["status_list_index"]) - step(5, "Revoked: verification fails and the public status bit is set") - - # 6. Audit - report = client.get(f"/audit/{GEOIDS[0]}/report", headers=owner).json() - assert report["all_chains_valid"] is True - expected = {"fieldlist.created": 1, "grant.issued": 1, "grant.retrieved": 1, "grant.revoked": 1} - assert report["events_by_type"] == expected, report["events_by_type"] - step(6, f"Audit chain valid, events: {report['events_by_type']}") + with fake_ar2_node(): + # 1. FieldList + fieldlist = client.post( + "/fieldlists", json={"name": "Finca Santa Rosa", "geoids": GEOIDS}, headers=owner + ).json() + step(1, f"FieldList created, ListID={fieldlist['list_id'][:16]}… ({len(fieldlist['geoids'])} fields)") + + # 2. Issue + issued = client.post( + "/grants/issue", + json={ + "list_id": fieldlist["list_id"], + "grantee_account": "hub-acct-eu-buyer", + "purpose": "eudr-due-diligence", + "validity_days": 30, + }, + headers=owner, + ).json() + step(2, f"Grant issued, jti={issued['jti']}, status index={issued['status_list_index']}") + + # 3. Retrieve via DPI account + received = client.get("/grants/received", headers=buyer).json() + assert len(received) == 1 and received[0]["jti"] == issued["jti"] + credential = received[0]["credential"] + step(3, "Buyer retrieved the credential with their DPI account (no OTP)") + + # 4. Verify + verdict = client.post("/grants/verify", json={"credential": credential}).json() + assert verdict["valid"] is True, verdict + assert len(verdict["disclosed_geoids"]) == 3 + step(4, f"Relying party verified: purpose={verdict['claims']['purpose']}, " + f"masking={verdict['claims']['masking_level']}, geoids disclosed={len(verdict['disclosed_geoids'])}") + + # 5. Revoke + revoked = client.post("/grants/revoke", json={"jti": issued["jti"]}, headers=owner).json() + assert revoked["status"] == "revoked" + verdict_after = client.post("/grants/verify", json={"credential": credential}).json() + assert verdict_after == {"valid": False, "reason": "credential revoked"} + status = StatusList.decode(client.get("/grants/status-list").json()["encoded"]) + assert status.is_revoked(issued["status_list_index"]) + step(5, "Revoked: verification fails and the public status bit is set") + + # 6. Audit + report = client.get(f"/audit/{GEOIDS[0]}/report", headers=owner).json() + assert report["all_chains_valid"] is True + expected = {"fieldlist.created": 1, "grant.issued": 1, "grant.retrieved": 1, "grant.revoked": 1} + assert report["events_by_type"] == expected, report["events_by_type"] + step(6, f"Audit chain valid, events: {report['events_by_type']}") print("DEMO PASSED: issue -> retrieve -> verify -> revoke -> audit all green") return 0 diff --git a/services/pancake_services/common/config.py b/services/pancake_services/common/config.py index 22b2a92..edffd7e 100644 --- a/services/pancake_services/common/config.py +++ b/services/pancake_services/common/config.py @@ -7,6 +7,10 @@ import os from dataclasses import dataclass, field +from dotenv import load_dotenv + +load_dotenv() +load_dotenv(os.path.join(os.path.dirname(__file__), "../../../../.env")) @dataclass(frozen=True) @@ -20,6 +24,7 @@ class Settings: ) ) hub_url: str = field(default_factory=lambda: os.environ.get("HUB_URL", "")) + ar2_node_url: str = field(default_factory=lambda: os.environ.get("AR2_NODE_URL", "http://localhost:8001")) status_list_uri: str = field( default_factory=lambda: os.environ.get( "STATUS_LIST_URI", "http://localhost:8100/grants/status-list" diff --git a/services/pancake_services/grants/auth.py b/services/pancake_services/grants/auth.py index 7c9aa4e..0bc0031 100644 --- a/services/pancake_services/grants/auth.py +++ b/services/pancake_services/grants/auth.py @@ -92,3 +92,32 @@ def get_current_user( db.commit() db.refresh(user) return user + +class VerificationError(Exception): + pass + +def verify_authority_credential( + token: str, + public_key_pem: bytes, + requested_scope: str = None, + local_status_list_path: str = None, +) -> dict: + from pancake_services.grants import sdjwt, statuslist + try: + # Authority credentials use a different VCT + result = sdjwt.verify(token, public_key_pem, expected_vct="agstack.org/credentials/traceforward-authority/v1") + except sdjwt.VerificationError as e: + raise VerificationError(str(e)) from e + + claims = result.claims + + status = (claims.get("status") or {}).get("status_list") + if not status: + raise VerificationError("authority credential has no status list") + if statuslist.is_revoked(status, local_path=local_status_list_path): + raise VerificationError("authority credential revoked") + + if requested_scope and claims.get("scope") not in (requested_scope, "global"): + raise VerificationError(f"insufficient scope: requested {requested_scope}, got {claims.get('scope')}") + + return claims diff --git a/services/pancake_services/grants/issuer.py b/services/pancake_services/grants/issuer.py index 5c145cf..3ed4506 100644 --- a/services/pancake_services/grants/issuer.py +++ b/services/pancake_services/grants/issuer.py @@ -8,6 +8,7 @@ from __future__ import annotations import base64 +import functools import os from dataclasses import dataclass @@ -69,8 +70,8 @@ def load_issuer_identity() -> IssuerIdentity: raw = os.environ.get(ENV_KEY) if not raw: raise RuntimeError( - f"{ENV_KEY} is not set. Generate one with: " - "python -m pancake_services.grants.testkit.mint_test_credentials --keygen" + f"{ENV_KEY} is not set. For local testing, use the testkit key:\n" + "export PANCAKE_ISSUER_KEY=$(cat services/pancake_services/grants/testkit/dev_keys/dev_issuer_private.pem)" ) key = _load_private_key(raw) private_pem = key.private_bytes( @@ -88,3 +89,13 @@ def load_issuer_identity() -> IssuerIdentity: private_key_pem=private_pem, public_key_pem=public_pem, ) + + +@functools.lru_cache() +def authority_pubkey() -> bytes: + """Pancake's own trust anchor for verifying authority credentials.""" + key_path = os.getenv("PANCAKE_TRUSTED_AUTHORITY_PUBKEY") + if not key_path or not os.path.exists(key_path): + raise RuntimeError("PANCAKE_TRUSTED_AUTHORITY_PUBKEY not set or file not found") + with open(key_path, "rb") as f: + return f.read() diff --git a/services/pancake_services/grants/merkle.py b/services/pancake_services/grants/merkle.py index aa679cc..cf81d1e 100644 --- a/services/pancake_services/grants/merkle.py +++ b/services/pancake_services/grants/merkle.py @@ -1,9 +1,9 @@ """Merkle ListID construction per services/specs/MERKLE_LISTID.md. -A FieldList's identifier (ListID) is the hex Merkle root over its member -GeoIDs: leaves are SHA-256 of the UTF-8 GeoID strings in lexicographic -order, parents are SHA-256(left || right), and an odd node is promoted -unchanged to the next level. +A List's identifier (ListID) is the hex Merkle root over its members +(GeoIDs, nested RegionIDs prefixed with R:, or nested ListIDs prefixed with L:). +Leaves are SHA-256 of the UTF-8 strings in lexicographic order. +Parents are SHA-256(left || right), and an odd node is promoted unchanged. """ from __future__ import annotations @@ -15,11 +15,11 @@ def _sha256(data: bytes) -> bytes: return hashlib.sha256(data).digest() -def canonical_members(geoids: List[str]) -> List[str]: - """Deduplicate and sort GeoIDs into canonical (lexicographic) order.""" - if not geoids: - raise ValueError("a FieldList must contain at least one GeoID") - return sorted(set(geoids)) +def canonical_members(members: List[str]) -> List[str]: + """Deduplicate and sort members into canonical (lexicographic) order.""" + if not members: + raise ValueError("a List must contain at least one member") + return sorted(set(members)) def _levels(members: List[str]) -> List[List[bytes]]: @@ -37,19 +37,19 @@ def _levels(members: List[str]) -> List[List[bytes]]: return levels -def merkle_root(geoids: List[str]) -> str: - """Compute the ListID (lowercase hex Merkle root) for a set of GeoIDs.""" - members = canonical_members(geoids) - return _levels(members)[-1][0].hex() +def merkle_root(members: List[str]) -> str: + """Compute the ListID (lowercase hex Merkle root) for a set of members.""" + canonical = canonical_members(members) + return _levels(canonical)[-1][0].hex() -def inclusion_proof(geoids: List[str], geoid: str) -> List[Dict[str, str]]: - """Build an inclusion proof (list of {sibling, position} steps) for one GeoID.""" - members = canonical_members(geoids) - if geoid not in members: - raise ValueError(f"GeoID not in list: {geoid}") - levels = _levels(members) - index = members.index(geoid) +def inclusion_proof(members: List[str], member: str) -> List[Dict[str, str]]: + """Build an inclusion proof (list of {sibling, position} steps) for one member.""" + canonical = canonical_members(members) + if member not in canonical: + raise ValueError(f"Member not in list: {member}") + levels = _levels(canonical) + index = canonical.index(member) proof: List[Dict[str, str]] = [] for level in levels[:-1]: pair_start = index - (index % 2) @@ -65,9 +65,9 @@ def inclusion_proof(geoids: List[str], geoid: str) -> List[Dict[str, str]]: return proof -def verify_inclusion(geoid: str, proof: List[Dict[str, str]], list_id: str) -> bool: +def verify_inclusion(member: str, proof: List[Dict[str, str]], list_id: str) -> bool: """Verify an inclusion proof against a ListID.""" - node = _sha256(geoid.encode("utf-8")) + node = _sha256(member.encode("utf-8")) for step in proof: sibling = bytes.fromhex(step["sibling"]) if step["position"] == "right": diff --git a/services/pancake_services/grants/models.py b/services/pancake_services/grants/models.py index afdf820..ae10c80 100644 --- a/services/pancake_services/grants/models.py +++ b/services/pancake_services/grants/models.py @@ -52,24 +52,8 @@ class FieldList(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) owner: Mapped[User] = relationship(back_populates="fieldlists") - members: Mapped[list["FieldListMember"]] = relationship( - back_populates="fieldlist", cascade="all, delete-orphan" - ) - - @property - def geoids(self) -> list[str]: - return sorted(m.geoid for m in self.members) - - -class FieldListMember(Base): - __tablename__ = "fieldlist_members" - __table_args__ = (UniqueConstraint("fieldlist_id", "geoid", name="uq_member"),) - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - fieldlist_id: Mapped[int] = mapped_column(ForeignKey("fieldlists.id"), index=True) - geoid: Mapped[str] = mapped_column(String(128), index=True) - - fieldlist: Mapped[FieldList] = relationship(back_populates="members") + + # members relationship removed (moved to AR2) class Grant(Base): diff --git a/services/pancake_services/grants/routers/audit.py b/services/pancake_services/grants/routers/audit.py index a89d9de..c2439ee 100644 --- a/services/pancake_services/grants/routers/audit.py +++ b/services/pancake_services/grants/routers/audit.py @@ -1,21 +1,29 @@ """OpenScience Auditing API: per-GeoID provenance from the signed MEAL ledger.""" from __future__ import annotations +import hmac +import httpx +import os from datetime import datetime, timezone from typing import Optional +from pydantic import BaseModel + from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import select from sqlalchemy.orm import Session from pancake_services.grants.auth import get_current_user, get_db from pancake_services.grants.mealstore import MealStore -from pancake_services.grants.models import FieldList, FieldListMember, Meal, MealPacket, User +from pancake_services.grants.models import Meal, MealPacket, User + router = APIRouter(prefix="/audit", tags=["audit"]) + def _packets_for_geoid( + request: Request, db: Session, geoid: str, since: Optional[datetime], @@ -23,14 +31,39 @@ def _packets_for_geoid( ) -> list[MealPacket]: """Events indexed directly on the geoid, plus events on any fieldlist (ListID) that contains it.""" - list_ids = set( - db.execute( - select(FieldList.list_id) - .join(FieldListMember, FieldListMember.fieldlist_id == FieldList.id) - .where(FieldListMember.geoid == geoid) - ).scalars() - ) + ar2_url = request.app.state.settings.ar2_node_url + headers = {} + if "authorization" in request.headers: + headers["authorization"] = request.headers["authorization"] + if "x-authority-token" in request.headers: + headers["x-authority-token"] = request.headers["x-authority-token"] + if "x-pancake-signature" in request.headers: + headers["x-pancake-signature"] = request.headers["x-pancake-signature"] + + list_ids = set() + try: + from pancake_services.grants.models import FieldList, FieldListMember + local_lists = set( + db.execute( + select(FieldList.list_id) + .join(FieldListMember, FieldListMember.fieldlist_id == FieldList.id) + .where(FieldListMember.geoid == geoid) + ).scalars() + ) + list_ids.update(local_lists) + except Exception: + pass + + try: + if ar2_url: + resp = httpx.get(f"{ar2_url}/list-artifact/reverse/{geoid}", headers=headers, timeout=10) + if resp.status_code == 200: + list_ids.update(resp.json().get("list_ids", [])) + except httpx.HTTPError: + pass # If AR2 fails or 404s, just use the geoid + keys = list(list_ids | {geoid}) + print(f"DEBUG keys: {keys}") query = select(MealPacket).where(MealPacket.geoid.in_(keys)) if since is not None: query = query.where(MealPacket.time_index >= since) @@ -55,12 +88,13 @@ def _packet_json(p: MealPacket) -> dict: @router.get("/{geoid}") def audit_events( geoid: str, + request: Request, since: Optional[datetime] = Query(default=None, alias="from"), until: Optional[datetime] = Query(default=None, alias="to"), user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - packets = _packets_for_geoid(db, geoid, since, until) + packets = _packets_for_geoid(request, db, geoid, since, until) return {"geoid": geoid, "event_count": len(packets), "events": [_packet_json(p) for p in packets]} @@ -73,7 +107,7 @@ def audit_report( ): """Compliance report: full provenance plus chain-integrity verification for every MEAL touching this GeoID.""" - packets = _packets_for_geoid(db, geoid, None, None) + packets = _packets_for_geoid(request, db, geoid, None, None) store = MealStore(request.app.state.issuer) meal_ids = sorted({p.meal_id for p in packets}) chains = {meal_id: store.verify_chain(db, meal_id) for meal_id in meal_ids} @@ -103,3 +137,66 @@ def verify_meal_chain( if meal is None: raise HTTPException(status_code=404, detail="meal not found") return MealStore(request.app.state.issuer).verify_chain(db, meal_id) + + +class AuditEventRequest(BaseModel): + event: str + who: str + credential_id: str + seed_geoid: str + scope: Optional[str] = None + match_count: Optional[int] = None + artifact: Optional[str] = None + list_ids: Optional[list[str]] = None + +@router.post("/events") +def append_audit_event( + body: AuditEventRequest, + request: Request, + db: Session = Depends(get_db) +): + """Internal endpoint for AR2 to append traceforward/traceback MEAL events.""" + internal_token = request.headers.get("X-Pancake-Internal") + expected = os.getenv("AR2_INTERNAL_SHARED_SECRET") + if not expected or not internal_token or not hmac.compare_digest(internal_token, expected): + raise HTTPException(status_code=403, detail="Not authorized") + + store = MealStore(request.app.state.issuer) + # the meal_key should be the seed_geoid or the artifact id + meal_key = body.seed_geoid if body.seed_geoid else body.artifact + if not meal_key: + raise HTTPException(status_code=400, detail="meal_key required") + + payload = { + "credential_id": body.credential_id, + "scope": body.scope, + "match_count": body.match_count, + "artifact": body.artifact + } + + # Log to the seed geoid or artifact id + store.append_event( + db, + meal_key=meal_key, + event_type=body.event, + author_account=body.who, + payload=payload, + geoid=meal_key, + meal_type="recall_audit" + ) + + # Log to all affected list IDs + if body.list_ids: + for list_id in body.list_ids: + store.append_event( + db, + meal_key=list_id, + event_type=body.event, + author_account=body.who, + payload=payload, + geoid=meal_key, + meal_type="recall_audit" + ) + + db.commit() + return {"status": "ok"} diff --git a/services/pancake_services/grants/routers/fieldlists.py b/services/pancake_services/grants/routers/fieldlists.py index b091f6a..2989bc8 100644 --- a/services/pancake_services/grants/routers/fieldlists.py +++ b/services/pancake_services/grants/routers/fieldlists.py @@ -1,6 +1,7 @@ """FieldList endpoints: owner-scoped GeoID lists identified by Merkle ListIDs.""" from __future__ import annotations +import httpx from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.orm import Session @@ -8,8 +9,8 @@ from pancake_services.grants import merkle from pancake_services.grants.auth import get_current_user, get_db from pancake_services.grants.mealstore import MealStore -from pancake_services.grants.models import FieldList, FieldListMember, User -from pancake_services.grants.schemas import FieldListCreate, FieldListOut, InclusionProofOut +from pancake_services.grants.models import FieldList, User +from pancake_services.grants.schemas import FieldListCreate, FieldListOut, InclusionProofOut, HoldersRequest, HoldersResponse router = APIRouter(prefix="/fieldlists", tags=["fieldlists"]) @@ -28,6 +29,20 @@ def _owned(db: Session, user: User, list_id: str) -> FieldList: return fieldlist +def _fetch_geoids(request: Request, list_id: str) -> list[str]: + ar2_url = request.app.state.settings.ar2_node_url + import os + headers = {"x-pancake-internal": os.getenv("AR2_INTERNAL_SHARED_SECRET", "true")} + if "authorization" in request.headers: + headers["authorization"] = request.headers["authorization"] + try: + resp = httpx.get(f"{ar2_url}/list-artifact/{list_id}", headers=headers, timeout=10) + resp.raise_for_status() + return resp.json().get("members", []) + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch list artifact from AR2: {e}") + + @router.post("", response_model=FieldListOut, status_code=201) def create_fieldlist( body: FieldListCreate, @@ -46,12 +61,23 @@ def create_fieldlist( return FieldListOut( list_id=existing.list_id, name=existing.name, - geoids=existing.geoids, + geoids=members, # Returning from request body created_at=existing.created_at, ) + # Call AR2 to register the list artifact + ar2_url = request.app.state.settings.ar2_node_url + headers = {} + if "authorization" in request.headers: + headers["authorization"] = request.headers["authorization"] + try: + resp = httpx.post(f"{ar2_url}/list-artifact", json={"members": members}, headers=headers, timeout=10) + resp.raise_for_status() + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Failed to register list artifact on AR2: {e}") + fieldlist = FieldList(list_id=list_id, name=body.name, owner_id=user.id) - fieldlist.members = [FieldListMember(geoid=g) for g in members] + # fieldlist.members no longer used db.add(fieldlist) db.flush() @@ -71,32 +97,101 @@ def create_fieldlist( @router.get("", response_model=list[FieldListOut]) -def list_fieldlists(user: User = Depends(get_current_user), db: Session = Depends(get_db)): +def list_fieldlists(request: Request, user: User = Depends(get_current_user), db: Session = Depends(get_db)): rows = db.execute(select(FieldList).where(FieldList.owner_id == user.id)).scalars() - return [ - FieldListOut(list_id=f.list_id, name=f.name, geoids=f.geoids, created_at=f.created_at) - for f in rows - ] + result = [] + for f in rows: + geoids = _fetch_geoids(request, f.list_id) + result.append(FieldListOut(list_id=f.list_id, name=f.name, geoids=geoids, created_at=f.created_at)) + return result + + +@router.post("/holders", response_model=HoldersResponse) +def resolve_holders( + body: HoldersRequest, + request: Request, + db: Session = Depends(get_db), +): + """Tier 3 identity disclosure. Requires BOTH the AR2 internal secret AND a + valid, in-scope authority credential. Every disclosure is written to MEAL + in the same transaction as the lookup.""" + import os + import hmac + + # (a) transport: only AR2 may call this at all + secret = os.getenv("AR2_INTERNAL_SHARED_SECRET") + presented = request.headers.get("X-Pancake-Internal") + if not secret or not presented or not hmac.compare_digest(presented.encode(), secret.encode()): + raise HTTPException(status_code=403, detail="not authorized") + + # (b) authorization: verify the credential ourselves - never on AR2's word + from pancake_services.grants.auth import verify_authority_credential, VerificationError + from pancake_services.grants.issuer import authority_pubkey + + authority_token = request.headers.get("X-Authority-Token") + if not authority_token: + raise HTTPException(status_code=403, detail="authority credential required") + try: + claims = verify_authority_credential( + authority_token, + authority_pubkey(), # Pancake's own trust anchor + requested_scope=body.scope, + local_status_list_path=os.getenv("TEST_STATUS_LIST_DIR"), + ) + except VerificationError as e: + raise HTTPException(status_code=403, detail=f"authority credential invalid: {e}") from None + + # (c) the lookup + holders = {} + if body.list_ids: + rows = db.execute( + select(FieldList.list_id, User.hub_account_id) + .join(User, FieldList.owner_id == User.id) + .where(FieldList.list_id.in_(body.list_ids)) + ).all() + holders = {list_id: acct for list_id, acct in rows} + + # (d) the disclosure is on the record, in the same transaction as the read + from pancake_services.grants.mealstore import MealStore + MealStore(request.app.state.issuer).append_event( + db, + meal_key=body.seed_geoid, + event_type="traceforward.disclosure", + author_account=claims.get("sub"), + payload={ + "credential_id": claims.get("jti"), + "scope": body.scope, + "disclosed_count": len(holders), + "requested_count": len(body.list_ids), + }, + geoid=body.seed_geoid, + meal_type="recall_audit", + ) + db.commit() + return HoldersResponse(holders=holders) @router.get("/{list_id}", response_model=FieldListOut) def get_fieldlist( - list_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db) + list_id: str, request: Request, user: User = Depends(get_current_user), db: Session = Depends(get_db) ): f = _owned(db, user, list_id) - return FieldListOut(list_id=f.list_id, name=f.name, geoids=f.geoids, created_at=f.created_at) + geoids = _fetch_geoids(request, list_id) + return FieldListOut(list_id=f.list_id, name=f.name, geoids=geoids, created_at=f.created_at) @router.get("/{list_id}/proof/{geoid}", response_model=InclusionProofOut) def inclusion_proof( list_id: str, geoid: str, + request: Request, user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - f = _owned(db, user, list_id) + _owned(db, user, list_id) + geoids = _fetch_geoids(request, list_id) try: - proof = merkle.inclusion_proof(f.geoids, geoid) + proof = merkle.inclusion_proof(geoids, geoid) except ValueError: raise HTTPException(status_code=404, detail="geoid not in fieldlist") from None return InclusionProofOut(geoid=geoid, list_id=list_id, proof=proof) diff --git a/services/pancake_services/grants/routers/grants.py b/services/pancake_services/grants/routers/grants.py index f5b702e..01ffa07 100644 --- a/services/pancake_services/grants/routers/grants.py +++ b/services/pancake_services/grants/routers/grants.py @@ -101,7 +101,20 @@ def issue_grant( "odrl": _build_odrl(jti, body.list_id, body.purpose, exp), "status": {"status_list": {"uri": settings.status_list_uri, "idx": index}}, } - credential = sdjwt.issue(claims, fieldlist.geoids, issuer.private_key_pem, issuer.kid) + # Fetch geoids from AR2 since they are no longer stored in Pancake + ar2_url = request.app.state.settings.ar2_node_url + import os + headers = {"x-pancake-internal": os.getenv("AR2_INTERNAL_SHARED_SECRET", "true")} + if "authorization" in request.headers: + headers["authorization"] = request.headers["authorization"] + try: + resp = httpx.get(f"{ar2_url}/list-artifact/{body.list_id}", headers=headers, timeout=10) + resp.raise_for_status() + geoids = resp.json().get("members", []) + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch list artifact from AR2: {e}") + + credential = sdjwt.issue(claims, geoids, issuer.private_key_pem, issuer.kid) grant = Grant( jti=jti, diff --git a/services/pancake_services/grants/schemas.py b/services/pancake_services/grants/schemas.py index 4d23552..c76b054 100644 --- a/services/pancake_services/grants/schemas.py +++ b/services/pancake_services/grants/schemas.py @@ -58,3 +58,11 @@ class StatusListOut(BaseModel): uri: str encoded: str size: int + +class HoldersRequest(BaseModel): + list_ids: List[str] + scope: str + seed_geoid: str + +class HoldersResponse(BaseModel): + holders: dict[str, str] diff --git a/services/pancake_services/grants/statuslist.py b/services/pancake_services/grants/statuslist.py index 4acbd6e..e8b248a 100644 --- a/services/pancake_services/grants/statuslist.py +++ b/services/pancake_services/grants/statuslist.py @@ -54,3 +54,40 @@ def decode(cls, encoded: str) -> "StatusList": sl = cls(size=len(raw) * 8) sl._bits = bytearray(raw) return sl + +def is_revoked(status: dict, local_path: str = None) -> bool: + import os + import json + import urllib.request + + uri = status.get("uri") + idx = status.get("idx") + if uri is None or idx is None: + raise ValueError("status missing uri or idx") + + status_list_data = None + if local_path: + filepath = os.path.join(local_path, "status_list.txt") + if not os.path.exists(filepath): + filename = uri.rstrip('/').split('/')[-1] + filepath = os.path.join(local_path, filename) + with open(filepath, "rb") as f: + status_list_data = f.read() + else: + req = urllib.request.Request(uri, headers={'Accept': 'application/statuslist+jwt'}) + with urllib.request.urlopen(req, timeout=10) as response: + status_list_data = response.read() + + # Try parsing as JSON first + encoded = None + try: + sl_json = json.loads(status_list_data) + encoded = sl_json.get("encoded") or sl_json.get("status_list", {}).get("lst") + except Exception: + pass + + if not encoded: + encoded = status_list_data.decode('utf-8').strip() + + sl = StatusList.decode(encoded) + return sl.is_revoked(idx) diff --git a/services/pancake_services/grants/testkit/fake_ar2.py b/services/pancake_services/grants/testkit/fake_ar2.py new file mode 100644 index 0000000..f5ab21a --- /dev/null +++ b/services/pancake_services/grants/testkit/fake_ar2.py @@ -0,0 +1,77 @@ +from contextlib import contextmanager +from unittest.mock import patch +import httpx +from pancake_services.grants.merkle import merkle_root + +@contextmanager +def fake_ar2_node(): + """In-process stand-in for the AR2 node: stateful registry + real BFS. + Used by services/tests/conftest.py and services/demo/end_to_end_demo.py.""" + class MockResponse: + def __init__(self, json_data, status_code=200): + self._json_data = json_data + self.status_code = status_code + + def json(self): + return self._json_data + + def raise_for_status(self): + if self.status_code >= 400: + raise httpx.HTTPError("mock error") + + original_post = httpx.post + original_get = httpx.get + + registry: dict[str, list[str]] = {} + + def mock_post(url, *args, **kwargs): + if url.endswith("/list-artifact"): + json_payload = kwargs.get("json", {}) + members = json_payload.get("members", []) + list_id = merkle_root(members) + registry[list_id] = sorted(set(members)) + return MockResponse({"list_id": list_id, "message": "Success"}) + elif "/traceforward" in url: + json_payload = kwargs.get("json", {}) + geoid = json_payload.get("seed_geoid", "") + + found = set() + frontier = set() + + for list_id, members in registry.items(): + if geoid in members: + frontier.add(list_id) + + found.update(frontier) + while frontier: + parents = set() + for list_id, members in registry.items(): + for member in members: + if member.startswith("L:") and member[2:] in frontier: + parents.add(list_id) + frontier = parents - found + found.update(parents) + + return MockResponse({ + "seed_geoid": geoid, + "tier": 1, + "matches": [{"list_id": lid, "region_id": None} for lid in found] + }) + return original_post(url, *args, **kwargs) + + def mock_get(url, *args, **kwargs): + if "/list-artifact/reverse/" in url: + geoid = url.rstrip("/").rsplit("/", 1)[-1] + return MockResponse({"list_ids": [lid for lid, m in registry.items() if geoid in m]}) + elif "/list-artifact/" in url: + list_id = url.rstrip("/").rsplit("/", 1)[-1] + if list_id not in registry: + return MockResponse({"detail": "not found"}, status_code=404) + return MockResponse({"members": registry[list_id]}) + return original_get(url, *args, **kwargs) + + with patch("pancake_services.grants.routers.fieldlists.httpx.post", side_effect=mock_post), \ + patch("pancake_services.grants.routers.audit.httpx.post", side_effect=mock_post), \ + patch("pancake_services.grants.routers.fieldlists.httpx.get", side_effect=mock_get), \ + patch("pancake_services.grants.routers.grants.httpx.get", side_effect=mock_get): + yield diff --git a/services/pancake_services/grants/testkit/mint_test_credentials.py b/services/pancake_services/grants/testkit/mint_test_credentials.py index d30d31c..daee2b1 100644 --- a/services/pancake_services/grants/testkit/mint_test_credentials.py +++ b/services/pancake_services/grants/testkit/mint_test_credentials.py @@ -1,7 +1,7 @@ """Mint the five test credentials verifier developers need. Usage: - python -m pancake_services.grants.testkit.mint_test_credentials [--keygen] [--out DIR] + python -m pancake_services.grants.testkit.mint_test_credentials [--out DIR] Generates (into --out, default services/pancake_services/grants/testkit/dev_keys/): dev_issuer_private.pem Ed25519 dev signing key (gitignored, generated fresh) @@ -156,15 +156,8 @@ def mint_all(out_dir: Path) -> dict: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out", default=str(Path(__file__).parent / "dev_keys")) - parser.add_argument("--keygen", action="store_true", - help="only print a fresh base64url Ed25519 seed for PANCAKE_ISSUER_KEY") args = parser.parse_args() - if args.keygen: - import secrets - print(_b64url(secrets.token_bytes(32))) - return - manifest = mint_all(Path(args.out)) print(f"Minted {len(manifest['credentials'])} test credentials into {args.out}") print(f"ListID: {manifest['list_id']}") diff --git a/services/requirements.txt b/services/requirements.txt index c4c3edc..3c6a272 100644 --- a/services/requirements.txt +++ b/services/requirements.txt @@ -1,11 +1,37 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.14.1 +certifi==2026.6.17 +cffi==2.1.0 +charset-normalizer==3.4.9 +click==8.4.2 +coverage==7.15.0 +cryptography==49.0.0 +dotenv==0.9.9 fastapi==0.139.0 -uvicorn==0.50.2 -SQLAlchemy==2.0.51 -PyJWT[crypto]==2.13.0 +greenlet==3.5.3 +h11==0.16.0 +httpcore==1.0.9 httpx==0.28.1 -PyYAML==6.0.3 -python-ulid==3.1.0 -requests==2.32.5 +idna==3.18 +iniconfig==2.3.0 +packaging==26.2 +pluggy==1.6.0 +pycparser==3.0 +pydantic==2.13.4 +pydantic_core==2.46.4 +Pygments==2.20.0 +PyJWT==2.13.0 pytest==9.1.1 pytest-cov==7.0.0 +python-dotenv==1.2.2 +python-ulid==3.1.0 +PyYAML==6.0.3 +requests==2.32.5 ruff==0.15.20 +SQLAlchemy==2.0.51 +starlette==1.3.1 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +urllib3==2.7.0 +uvicorn==0.50.2 diff --git a/services/tests/conftest.py b/services/tests/conftest.py index 4222851..82c796b 100644 --- a/services/tests/conftest.py +++ b/services/tests/conftest.py @@ -4,8 +4,10 @@ import time from pathlib import Path + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # make pancake_services importable + import jwt as pyjwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa @@ -14,6 +16,7 @@ from pancake_services.common.config import Settings from pancake_services.grants.app import create_app from pancake_services.grants.issuer import IssuerIdentity, generate_keypair_pem +from pancake_services.grants.testkit.fake_ar2 import fake_ar2_node def _b64url_uint(n: int) -> str: @@ -142,3 +145,21 @@ def fieldlist(client, owner_headers, geoids): ) assert response.status_code == 201, response.text return response.json() + +@pytest.fixture(autouse=True) +def mock_ar2(): + with fake_ar2_node(): + yield + + +REQUIRED = {"test_revoked_credential_rejected_by_both_layers", + "test_valid_credential_accepted_by_both_layers"} + +def pytest_sessionfinish(session, exitstatus): + """Cross-layer tests are load-bearing: a skip is a failure, not a pass.""" + skipped = {r.nodeid.split("::")[-1] for r in session.config.pluginmanager + .get_plugin("terminalreporter").stats.get("skipped", []) + for r in [r]} + missed = REQUIRED & skipped + if missed: + raise pytest.UsageError(f"cross-layer tests skipped, not run: {sorted(missed)}") diff --git a/services/tests/test_fieldlists.py b/services/tests/test_fieldlists.py index 28f58f9..6c0822a 100644 --- a/services/tests/test_fieldlists.py +++ b/services/tests/test_fieldlists.py @@ -1,5 +1,6 @@ """FieldList endpoints: idempotent creation, owner scoping, proofs.""" from pancake_services.grants.merkle import merkle_root, verify_inclusion +import pytest def test_create_returns_merkle_listid(client, owner_headers, geoids): @@ -58,3 +59,212 @@ def test_proof_for_nonmember_404(client, owner_headers, fieldlist): list_id = fieldlist["list_id"] response = client.get(f"/fieldlists/{list_id}/proof/unknown-geoid", headers=owner_headers) assert response.status_code == 404 + + + + +@pytest.mark.parametrize("row_name, headers_func, expected_status", [ + ("1 farmer, no secret, no cred", lambda o, t: o, 403), + ("2 farmer, no secret, valid cred", lambda o, t: {**o, "X-Authority-Token": t["valid"]}, 403), + ("3 anonymous", lambda o, t: {}, 403), + ("4 AR2, secret, no cred", lambda o, t: {"X-Pancake-Internal": "test-secret"}, 403), + ("5 AR2, wrong secret, valid cred", lambda o, t: {"X-Pancake-Internal": "wrong", "X-Authority-Token": t["valid"]}, 403), + ("6 AR2, secret, expired cred", lambda o, t: {"X-Pancake-Internal": "test-secret", "X-Authority-Token": t["expired"]}, 403), + ("7 AR2, secret, revoked cred", lambda o, t: {"X-Pancake-Internal": "test-secret", "X-Authority-Token": t["revoked"]}, 403), + ("8 AR2, secret, out-of-scope cred", lambda o, t: {"X-Pancake-Internal": "test-secret", "X-Authority-Token": t["out_of_scope"]}, 403), + ("9 AR2, secret, valid & in scope", lambda o, t: {"X-Pancake-Internal": "test-secret", "X-Authority-Token": t["valid"]}, 200), +]) +def test_resolve_holders_matrix(row_name, headers_func, expected_status, client, owner_headers, fieldlist, dev_issuer, monkeypatch, tmp_path): + import time + from pancake_services.grants import sdjwt + + monkeypatch.setenv("AR2_INTERNAL_SHARED_SECRET", "test-secret") + pubkey_path = tmp_path / "test_authority_pubkey.pem" + pubkey_path.write_bytes(dev_issuer.public_key_pem) + monkeypatch.setenv("PANCAKE_TRUSTED_AUTHORITY_PUBKEY", str(pubkey_path)) + monkeypatch.setenv("TEST_STATUS_LIST_DIR", str(tmp_path)) + + import json + import base64 + import zlib + def create_status_list(revoked_indices): + lst = bytearray(16) + for idx in revoked_indices: + lst[idx // 8] |= (1 << (idx % 8)) + compressed = zlib.compress(bytes(lst)) + return {"status_list": {"bits": 1, "lst": base64.urlsafe_b64encode(compressed).decode('utf-8').rstrip('=')}} + + with open(tmp_path / "local", "w") as f: + json.dump(create_status_list([1]), f) + + list_id = fieldlist["list_id"] + seed_geoid = "fake-seed" + + def issue_token(scope="demo-recall", exp_offset=3600, status_idx=0): + claims = { + "iss": dev_issuer.issuer_id, + "sub": "auth-subject", + "iat": int(time.time()), + "exp": int(time.time()) + exp_offset, + "vct": "agstack.org/credentials/traceforward-authority/v1", + "scope": scope, + "status": {"status_list": {"uri": "local", "idx": status_idx}}, + } + return sdjwt.issue(claims, [], dev_issuer.private_key_pem, dev_issuer.kid) + + tokens = { + "valid": issue_token(status_idx=0), + "expired": issue_token(exp_offset=-3600), + "out_of_scope": issue_token(scope="wrong-scope"), + "revoked": issue_token(status_idx=1) + } + + req_body = {"list_ids": [list_id], "scope": "demo-recall", "seed_geoid": seed_geoid} + + headers = headers_func(owner_headers, tokens) + res = client.post("/fieldlists/holders", json=req_body, headers=headers) + + assert res.status_code == expected_status + if expected_status == 200: + assert res.json()["holders"] == {list_id: "hub-acct-owner"} + audit_res = client.get(f"/audit/{seed_geoid}/report", headers=owner_headers) + assert audit_res.status_code == 200 + events = audit_res.json()["events"] + assert len(events) == 1 + assert events[0]["event"]["event_type"] == "traceforward.disclosure" + + +def test_holders_rejects_credential_without_status_list(client, owner_headers, fieldlist, dev_issuer, monkeypatch, tmp_path): + import time + from pancake_services.grants import sdjwt + monkeypatch.setenv("AR2_INTERNAL_SHARED_SECRET", "test-secret") + pubkey_path = tmp_path / "test_authority_pubkey.pem" + pubkey_path.write_bytes(dev_issuer.public_key_pem) + monkeypatch.setenv("PANCAKE_TRUSTED_AUTHORITY_PUBKEY", str(pubkey_path)) + + list_id = fieldlist["list_id"] + claims = { + "iss": dev_issuer.issuer_id, + "sub": "auth-subject", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + "vct": "agstack.org/credentials/traceforward-authority/v1", + "scope": "demo-recall", + } + token = sdjwt.issue(claims, [], dev_issuer.private_key_pem, dev_issuer.kid) + + req_body = {"list_ids": [list_id], "scope": "demo-recall", "seed_geoid": "fake-seed"} + res = client.post("/fieldlists/holders", json=req_body, headers={"X-Pancake-Internal": "test-secret", "X-Authority-Token": token}) + assert res.status_code == 403 + +def _setup_ar2_and_pancake(monkeypatch, tmp_path, dev_issuer): + import time + import json + import base64 + import zlib + from pancake_services.grants import sdjwt + + # Pancake env + monkeypatch.setenv("AR2_INTERNAL_SHARED_SECRET", "test-secret") + pubkey_path = tmp_path / "test_authority_pubkey.pem" + pubkey_path.write_bytes(dev_issuer.public_key_pem) + monkeypatch.setenv("PANCAKE_TRUSTED_AUTHORITY_PUBKEY", str(pubkey_path)) + monkeypatch.setenv("TEST_STATUS_LIST_DIR", str(tmp_path)) + + # AR2 env + monkeypatch.setenv("AR_TRUSTED_AUTHORITY_PUBKEY", str(pubkey_path)) + + def create_status_list(revoked_indices): + lst = bytearray(16) + for idx in revoked_indices: + lst[idx // 8] |= (1 << (idx % 8)) + compressed = zlib.compress(bytes(lst)) + return {"status_list": {"bits": 1, "lst": base64.urlsafe_b64encode(compressed).decode('utf-8').rstrip('=')}} + + with open(tmp_path / "local", "w") as f: + json.dump(create_status_list([1]), f) + + def issue_token(status_idx=0): + claims = { + "iss": dev_issuer.issuer_id, + "sub": "auth-subject", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + "vct": "agstack.org/credentials/traceforward-authority/v1", + "scope": "demo-recall", + "status": {"status_list": {"uri": "local", "idx": status_idx}}, + } + return sdjwt.issue(claims, [], dev_issuer.private_key_pem, dev_issuer.kid) + + return issue_token(status_idx=0), issue_token(status_idx=1) + +def test_revoked_credential_rejected_by_both_layers(client, fieldlist, dev_issuer, monkeypatch, tmp_path): + import sys + import os + import pytest + + ar2_paths = [ + os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../ar2')), + os.path.abspath(os.path.join(os.path.dirname(__file__), '../../ar2')) + ] + ar2_path = next((p for p in ar2_paths if os.path.exists(p)), None) + + if not ar2_path: + pytest.skip("AR2 repository not available for cross-layer test") + if ar2_path not in sys.path: + sys.path.append(ar2_path) + from unittest.mock import MagicMock + sys.modules['pyproj'] = MagicMock() + sys.modules['h3'] = MagicMock() + sys.modules['psycopg2'] = MagicMock() + import os + os.environ['DATABASE_URL'] = 'sqlite:///:memory:' + from app.main import app as ar2_app + from fastapi.testclient import TestClient + ar2_client = TestClient(ar2_app) + + valid_token, revoked_token = _setup_ar2_and_pancake(monkeypatch, tmp_path, dev_issuer) + monkeypatch.setattr('app.auth.verify_token', lambda token: {"sub": "test", "masking_level": 1}) + + # Rejected by both + assert ar2_client.post("/traceforward", headers={"X-Authority-Token": revoked_token, "Authorization": "Bearer test"}, json={"seed_geoid": "fake-seed"}).status_code == 403 + assert client.post("/fieldlists/holders", + headers={"X-Pancake-Internal": "test-secret", "X-Authority-Token": revoked_token}, + json={"list_ids": [fieldlist["list_id"]], "scope": "demo-recall", "seed_geoid": "fake-seed"}).status_code == 403 + +def test_valid_credential_accepted_by_both_layers(client, fieldlist, dev_issuer, monkeypatch, tmp_path): + import sys + import os + import pytest + + ar2_paths = [ + os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../ar2')), + os.path.abspath(os.path.join(os.path.dirname(__file__), '../../ar2')) + ] + ar2_path = next((p for p in ar2_paths if os.path.exists(p)), None) + + if not ar2_path: + pytest.skip("AR2 repository not available for cross-layer test") + if ar2_path not in sys.path: + sys.path.append(ar2_path) + from unittest.mock import MagicMock + sys.modules['pyproj'] = MagicMock() + sys.modules['h3'] = MagicMock() + sys.modules['psycopg2'] = MagicMock() + import os + os.environ['DATABASE_URL'] = 'sqlite:///:memory:' + from app.main import app as ar2_app + from fastapi.testclient import TestClient + ar2_client = TestClient(ar2_app) + + valid_token, revoked_token = _setup_ar2_and_pancake(monkeypatch, tmp_path, dev_issuer) + + monkeypatch.setattr('app.auth.verify_token', lambda token: {"sub": "test", "masking_level": 1}) + + res_ar2 = ar2_client.post("/traceforward", headers={"X-Authority-Token": valid_token, "Authorization": "Bearer test"}, json={"seed_geoid": "fake-seed"}) + assert res_ar2.status_code != 401, res_ar2.text + + res_pancake = client.post("/fieldlists/holders", + headers={"X-Pancake-Internal": "test-secret", "X-Authority-Token": valid_token}, + json={"list_ids": [fieldlist["list_id"]], "scope": "demo-recall", "seed_geoid": "fake-seed"}) + assert res_pancake.status_code == 200