Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
103 commits
Select commit Hold shift + click to select a range
0372f33
feat(pebble): kill switch on internal-key writes (PEBBLE_WRITES_DISAB…
May 6, 2026
6e24d4d
docs(pebble): unified search spec — synthesizes 3 deep design passes
May 6, 2026
2f2367f
feat(pebble): pebble_write_audit + search_audit_access_log migrations
May 6, 2026
62743fb
feat(pebble): startup assertion on BEDROCK_API_URL + INTERNAL_API_KEY
May 6, 2026
f90099a
feat(auth): X-Originating-User + X-Request-Id mandatory on internal-key
May 6, 2026
30b9a10
feat(stages): canonical Salesforce stage source — sf_stages module + …
May 6, 2026
c1acc78
feat(search): bedrock.search_doc + search_index_queue + search_audit
May 6, 2026
130000f
feat(search): /api/search read path — service + route + 56 tests
May 6, 2026
aa8b419
feat(search): indexer worker + composers + source-table triggers
May 6, 2026
94d86dc
feat(search): mount /api/search router + start indexer in lifespan
May 6, 2026
bc50b8c
feat(pebble): /api/pebble/ask proxy — Bedrock gateway to Pebble chat
May 6, 2026
d564d19
feat(frontend-v2): GlobalSearch dual-mode + vitest infrastructure
May 7, 2026
3f310b9
fix(search): resolve_principal — delegate to canonical permission res…
May 7, 2026
594fb1e
fix(pebble): plumb X-Originating-User into every crm_bridge call
May 7, 2026
83b7055
feat(audit): wire pebble_write_audit to every internal-key write route
May 7, 2026
ae57b7a
feat(pebble): cost cap + 80% degrade-to-L0 on /api/pebble/ask
May 7, 2026
2512eac
fix(verification): bugs caught by 10x deeper verification pass
May 7, 2026
9ba2955
feat(pebble): orchestrator foundation — every agentic pattern
May 7, 2026
45fbac6
feat(pebble): orchestrator-worker chat loop — planner, executor, eval…
May 7, 2026
94393fd
docs(pebble): PARKED warning — branch not production-ready
May 7, 2026
a2d3c2f
feat(pebble): wire ChatOrchestrator into chat path — Anthropic client…
May 8, 2026
69d495d
feat(frontend-v2): /pebble route + Pebble panel + GlobalSearch SSE — …
May 8, 2026
1cdc859
chore(pebble-phase-0): update parking notes for L1 + gitignore TS bui…
May 8, 2026
68e7e5a
fix(pebble): stream_orchestrator_events reuses AnthropicLLMClient sin…
May 8, 2026
7628090
feat(pebble): running cost+token tally + collapse-to-deck conversatio…
May 9, 2026
7ab5171
chore(frontend-v2): drop --noEmit from typecheck
May 11, 2026
051a9f7
docs(pebble): unpark phase-0 branch — open for review
May 11, 2026
87062a5
docs(pebble): chisel scoping — locked decisions for tool/skill author…
May 11, 2026
424b72b
docs(pebble): chisel plan + scoping verification pass — locked for Ph…
May 22, 2026
e70fae9
feat(chisel): scaffold framework — no-op autoload, manifest models, s…
May 22, 2026
3bdcbcb
test(chisel): Phase A.1 framework unit tests — 27 cases
May 22, 2026
98461e9
feat(chisel): migrate request_human_review to manifest
May 22, 2026
d1118c5
feat(chisel): migrate generate_chart to manifest
May 22, 2026
cae2ffd
feat(chisel): migrate get_record to manifest
May 22, 2026
60ec903
feat(chisel): migrate search_crm to manifest
May 22, 2026
1dfd4aa
feat(chisel): migrate weekly_pipeline_review workflow + cutover
May 22, 2026
8927199
docs(pebble): README — authoring tools and workflows with Chisel
May 22, 2026
b1b3dbb
feat(chisel): Phase B — eval harness + canonical queries
May 23, 2026
dfd45cc
refactor(chisel): cleanup pass — kill speculative abstractions, colla…
May 23, 2026
692ad35
feat(chisel): Phase C.1 — read-only HTTP API for the GUI surface
May 24, 2026
72611b7
feat(chisel): Phase C.2 — Bedrock proxy + React browser page
May 24, 2026
7bda3f2
feat(chisel): Phase C.3 — manifest-save service + write endpoints
May 24, 2026
f9e2496
fix(research): F1 — quorum verification fail-closed
May 24, 2026
27d3170
feat(research): F2 — claim dedup pre-quorum
May 24, 2026
ce61696
fix(research): F3 — verify_urls fail-closed + shared client
May 24, 2026
219d005
fix(research): F4 — name-match validation on fetched sources
May 24, 2026
8d7dfe1
fix(research): F5 — synthesis output anchored to claim_ids
May 24, 2026
410f34b
fix(research): F8 — deterministic confidence rubric
May 24, 2026
22853e7
fix(research): F7 — cross-source role-conflict detection
May 24, 2026
f241538
perf(research): P2 + P3 — parallelize tail writes and stage1/foragers
May 24, 2026
583282f
perf(research): P4 — background source_scores write
May 24, 2026
6b564c2
fix(research): preserve synthesis fields through the pipeline + lock …
May 24, 2026
4595f8f
fix(research): F10 — freshness-weighted claim ranking
May 24, 2026
296ce0a
fix(research): F11 — budget exhaustion fails closed, no unverified leak
May 24, 2026
7821dfb
fix(research): F12 — forager source-URL anchoring (defend against hal…
May 24, 2026
5d0e2eb
fix(research): F12 follow-up — stage1 LLM extractor also URL-anchored
May 24, 2026
ea2d299
test(research): pipeline edge-case invariants
May 24, 2026
2b1b55c
feat(research): F9 — claim-pool fingerprint for run-comparison + drif…
May 24, 2026
812fd9a
fix(research): tighten verifier system prompts to bias toward rejection
May 24, 2026
960acea
feat(research): research_quality_report — operator-facing trust summary
May 24, 2026
111b95a
feat(research): F13 — source-domain credibility tiers
May 24, 2026
d5f3c72
feat(research): synthesis prompt teaches source_tier semantics
May 24, 2026
05d6f5f
fix(research): F4 extension — name-match on EDGAR search results
May 24, 2026
eae4b1b
feat(research): export — surface F5 citations + F7 conflicts + F9 fin…
May 24, 2026
b2ba05b
feat(research): /api/v1/research/profiles/{contact_id}/quality endpoint
May 24, 2026
c389746
chore(research): record per-prospect cost_usd on save_profile
May 24, 2026
92d61c4
fix(research): F14 — per-source fetch error tracking
May 24, 2026
d061c01
feat(research): export — render F14 unreachable-sources section
May 24, 2026
5ed8293
fix(research): audit-write _log_result is fail-soft
May 24, 2026
be4c56c
fix(research): all audit-log writes route through fail-soft _safe_log
May 24, 2026
24da690
feat(research): export — evidence summary in the header
May 24, 2026
82fe745
fix(research): cancel-path early returns await background_tasks + flo…
May 24, 2026
42ab7d5
feat(research): research_quality_report — source-tier breakdown + err…
May 24, 2026
aecb7e9
fix(research): _safe_truncate fails soft on non-JSON-serializable claims
May 24, 2026
4f0e153
fix(research): defensive parsing of prospect.organizations
May 24, 2026
1992ecc
docs(research): F-series invariant ledger in _pipeline module docstring
May 24, 2026
acd1232
fix(research): F12 — also enforce http(s)://-shaped source_url
May 24, 2026
2bbca17
test(research): lock skipped_sources surfaces in synthesis system prompt
May 24, 2026
7fd2922
feat(research): stamp PIPELINE_VERSION + generated_at on saved profiles
May 24, 2026
1de3791
fix(research): forager prompts explicitly forbid URL invention
May 24, 2026
698aca8
fix(research): api_response_extractor prompt forbids URL invention
May 24, 2026
2fc324b
test(research): high-quality pool earns proper confidence end-to-end
May 24, 2026
e666e46
feat(research): quality report exposes pipeline_version + generated_at
May 24, 2026
43a336c
test(research): export Markdown smoke — fully-populated profile
May 24, 2026
d103c2b
feat(research): synthesis prompt shows pool source-tier distribution
May 24, 2026
da9e833
feat(research): F15 — synth prompt anchors time-sensitive facts to da…
May 26, 2026
b97d4d8
test(research): messy-prospect end-to-end resilience demo + F7 widening
May 26, 2026
d1e1719
chore(research): _safe_truncate warns when byte-cap drops items
May 26, 2026
4335c82
fix(research): tail-gather resilient — one save failure doesn't abort…
May 26, 2026
5d1c526
feat(research): F16 — verifiers see source_tier annotation in claims_…
May 26, 2026
aa3cdd2
chore(research): PIPELINE_VERSION → fidelity-v1.16; ledger documents …
May 26, 2026
6c81a01
feat(research): F17 — per-claim agent_source attribution
May 26, 2026
e6a47ca
feat(research): quality report exposes agent_source_counts
May 26, 2026
709e160
feat(research): conflicts inline conflicting claim texts
May 26, 2026
a9236a9
chore(research): PIPELINE_VERSION → fidelity-v1.18 (conflict claim_te…
May 26, 2026
3abc387
feat(research): F18 — synth prompt forbids paraphrasing proper nouns …
May 26, 2026
1cbf1dd
feat(research): F19 — post-LLM proper-noun grounding check
May 26, 2026
f5d90e6
perf(research): P1.1 — fec.py native async + shared httpx client
May 26, 2026
030806c
perf(research): P1.2 — phase-1 + phase-2 fetchers async-native
May 26, 2026
2f3e2c8
perf(research): P1.3a — convert remaining callsites for already-async…
May 26, 2026
245e3f5
Merge branch 'feat/pebble-chisel-phase-c' into feat/pebble-rollup
May 26, 2026
63133a6
Merge origin/dev into feat/pebble-rollup
May 26, 2026
216093b
docs: rollup summary + remaining-work triage for Jac's review
May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions financial_forecasting/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,50 @@ async def get_current_user_dep(request: Request) -> Optional[Dict]:

_BEDROCK_INTERNAL_API_KEY = os.getenv("BEDROCK_INTERNAL_API_KEY", "")

# HTTP methods considered "writes" for the kill switch below. Idempotent reads
# (GET, HEAD, OPTIONS) remain available even when the switch is on so that
# Pebble's research/lookup paths keep working during write-side incidents.
_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})

# Default scope grant for the internal-key principal. v1.0 grants the
# superset; later phases tighten via per-call X-Pebble-Scopes header.
# `*` is interpreted as "all scopes" by check_permission_or_internal.
_DEFAULT_INTERNAL_SCOPES = ("*",)


def _pebble_writes_disabled() -> bool:
"""Read the kill switch fresh on every call so operators can flip it
without a redeploy. Truthy values: "true" / "1" / "yes" (case-insensitive).
"""
return os.getenv("PEBBLE_WRITES_DISABLED", "").strip().lower() in {"true", "1", "yes"}


def _parse_scopes(header_value: str) -> tuple[str, ...]:
"""Parse the optional X-Pebble-Scopes header. Comma-separated scope
strings; empty / unset header = full grant via the wildcard ``*``.
"""
raw = (header_value or "").strip()
if not raw:
return _DEFAULT_INTERNAL_SCOPES
scopes = tuple(s.strip() for s in raw.split(",") if s.strip())
return scopes or _DEFAULT_INTERNAL_SCOPES


def _is_valid_originating_user_email(email: str) -> bool:
"""Lightweight shape check on X-Originating-User. We don't verify
membership in org_users here (would require a DB hit on every
request); the route-level audit insert (FK to org_users via
originating_user_email lookup) is the durable check. Spoofs against
the API key still produce auditable rows.
"""
if not email or len(email) > 254:
return False
if "@" not in email or email.startswith("@") or email.endswith("@"):
return False
if any(c.isspace() for c in email):
return False
return True


async def require_auth_or_internal(request: Request) -> Dict:
"""Authorize via internal API key (service-to-service) or user JWT.
Expand All @@ -150,13 +194,101 @@ async def require_auth_or_internal(request: Request) -> Dict:
synthetic service user dict. Otherwise falls back to require_auth.
Dev mode: if BEDROCK_INTERNAL_API_KEY is empty, internal key check
is skipped and only JWT auth is tried.

**Mandatory headers when using internal-key auth (Phase 0.2):**

- ``X-Originating-User``: the human whose session triggered this
service-to-service call. Required on EVERY internal-key request,
reads and writes alike. The email is propagated to
``bedrock.pebble_write_audit`` and to permission resolution so
that Pebble acts as a delegated principal, not a god principal.
Three independent adversarial reviews flagged the un-attributed
"service:pebble" pattern as a 1.0 blocker; this enforcement
closes that gap.
- ``X-Request-Id``: a UUIDv7 (or any UUID) for replay defense via
``UNIQUE(request_id)`` on ``bedrock.pebble_write_audit``. Format
checked here; uniqueness checked at the audit-row INSERT in the
route handler.

Optional header:

- ``X-Pebble-Scopes``: comma-separated scopes the caller is
requesting for THIS request. Default = ``("*",)`` (full grant).
``check_permission_or_internal`` verifies the matching scope is
present. Future tightening: Pebble will request narrow scopes
per call instead of carrying the full grant.

Kill switch: if PEBBLE_WRITES_DISABLED env is truthy AND the caller
is using a valid internal key AND the request method is a write
(POST/PUT/PATCH/DELETE), return 503. Reads via internal key and all
JWT-authenticated requests are unaffected.
"""
internal_key = request.headers.get("X-Internal-Key", "")
if _BEDROCK_INTERNAL_API_KEY and internal_key:
if hmac.compare_digest(internal_key, _BEDROCK_INTERNAL_API_KEY):
if _pebble_writes_disabled() and request.method in _WRITE_METHODS:
logger.warning(
"pebble_writes_disabled: blocking %s %s for service caller",
request.method,
request.url.path,
)
raise HTTPException(
status_code=503,
detail={
"error": "pebble_writes_disabled",
"message": (
"Pebble service-account writes are temporarily "
"disabled. Reads remain available."
),
},
)

originating_user = request.headers.get("X-Originating-User", "").strip()
if not _is_valid_originating_user_email(originating_user):
logger.warning(
"internal_key_missing_originating_user: %s %s",
request.method,
request.url.path,
)
raise HTTPException(
status_code=401,
detail={
"error": "originating_user_required",
"message": (
"X-Originating-User header is required on every "
"internal-key request. Pebble acts on behalf of a "
"specific user, never as itself."
),
},
)

request_id = request.headers.get("X-Request-Id", "").strip()
if not request_id:
logger.warning(
"internal_key_missing_request_id: %s %s",
request.method,
request.url.path,
)
raise HTTPException(
status_code=401,
detail={
"error": "request_id_required",
"message": (
"X-Request-Id header is required on every "
"internal-key request for replay defense. "
"Pass a UUID."
),
},
)

scopes = _parse_scopes(request.headers.get("X-Pebble-Scopes", ""))

return {
"user_id": "service:pebble",
"email": "pebble@internal",
"is_service": True,
"originating_user_email": originating_user,
"request_id": request_id,
"scopes": scopes,
}
return await require_auth(request)
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
-- 2026-05-06: bedrock.pebble_write_audit + bedrock.search_audit_access_log
--
-- Phase 0.1 + 0.7 of the Pebble 1.0 plan (tasks/pebble-search-spec.md).
--
-- Why:
-- When Pebble (or any service-to-service caller using
-- X-Internal-Key) writes through Bedrock's API, today's audit
-- trail collapses to a single synthetic user "service:pebble"
-- across logger.info lines and SF "LastModifiedById" — the
-- originating end-user is unrecoverable. Three independent
-- reviews (security/UX/architecture adversaries) flagged this
-- as a 1.0 blocker.
--
-- bedrock.pebble_write_audit captures every internal-key write
-- with the originating user, request id, route, payload hash,
-- and response status. UNIQUE(request_id) doubles as the
-- idempotency / replay-defense store for X-Request-Id (Phase
-- 0.7) — a duplicate request_id within 24h yields a 409 from
-- the API rather than a duplicate write.
--
-- bedrock.search_audit_access_log captures admins reading
-- other users' search history when query_text is revealed.
-- Lives separately from the search_audit table itself so that
-- RTBF / DSAR purges of search_audit cannot also erase the
-- accountability trail of who looked at what.
--
-- Related:
-- * tasks/pebble-search-spec.md (decisions §3, §4)
-- * tasks/pebble-search-spec-security.md §1.5, §2, §10.4
-- * tasks/pebble-overhaul-plan.md §0.1, §0.7
-- * financial_forecasting/auth.py:require_auth_or_internal
-- (enforces X-Originating-User; this table receives the
-- audit row from each write route)
--
-- Idempotent — safe to re-run.
--
-- Apply as bedrock owner:
-- psql "$DATABASE_URL" -f 2026-05-06-pebble-write-audit.sql

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS citext;

CREATE TABLE IF NOT EXISTS bedrock.pebble_write_audit (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),

-- Idempotency / replay defense. Required on every internal-key
-- write; UNIQUE means a 24h replay returns 409 from INSERT.
request_id UUID NOT NULL,

-- Routing / classification.
route TEXT NOT NULL, -- e.g. "/api/opportunities/update-stage"
http_method TEXT NOT NULL CHECK (http_method IN
('POST', 'PUT', 'PATCH', 'DELETE')),

-- Subject. nullable so an early-failure audit row (e.g. invalid
-- payload) still gets written for forensics.
sf_object_type TEXT, -- 'Opportunity' | 'Account' | 'Contact' | 'Task' | 'Payment' | 'Award' | 'Project'
sf_object_id TEXT,

-- Attribution. originating_user_email is the human whose
-- session triggered the workflow; service_user is "service:pebble"
-- (or future siblings). Both required.
originating_user_email CITEXT NOT NULL,
service_user TEXT NOT NULL DEFAULT 'service:pebble',

-- What was sent. payload_hash = sha256 of canonical JSON; full
-- payload kept for 90 days where the route opts in (sensitive
-- routes pass NULL to suppress).
payload_hash TEXT,
payload JSONB,

-- What came back.
response_status SMALLINT NOT NULL,
response_summary JSONB, -- e.g. {"award_created": true, "stage": "..."}
error_class TEXT, -- ExceptionClass name when status >= 400

-- Side-effect attestation. ensure_for_opp + future event-bus
-- subscribers write back here so a forensic reader can see the
-- full chain from one INSERT (vs. correlating across 3 tables).
side_effects JSONB, -- e.g. {"award_created": true, "activity_logged": true}

-- Latency for ops dashboards.
latency_ms INT,

-- Multi-tenant outer guard. Single value today, present so
-- search_audit / pebble_audit share an enforcement pattern.
org_id TEXT NOT NULL DEFAULT 'pursuit',

-- Replay defense: the duplicate-request_id detector relies on this.
UNIQUE (request_id)
);

CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_user_time
ON bedrock.pebble_write_audit(originating_user_email, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_object
ON bedrock.pebble_write_audit(sf_object_type, sf_object_id, occurred_at DESC)
WHERE sf_object_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_errors
ON bedrock.pebble_write_audit(occurred_at DESC)
WHERE response_status >= 400;
CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_route_time
ON bedrock.pebble_write_audit(route, occurred_at DESC);

COMMENT ON TABLE bedrock.pebble_write_audit IS
'Audit row for every internal-key write through Bedrock (Phase 0.1). UNIQUE(request_id) doubles as 24h replay defense (Phase 0.7).';
COMMENT ON COLUMN bedrock.pebble_write_audit.originating_user_email IS
'Mandatory. The human whose session triggered the workflow. Sourced from X-Originating-User header. require_auth_or_internal rejects calls without it.';
COMMENT ON COLUMN bedrock.pebble_write_audit.request_id IS
'Mandatory. UUIDv7 from X-Request-Id header. Replay window = 24h via UNIQUE constraint; deduplicate at the route via INSERT...ON CONFLICT (request_id) DO NOTHING RETURNING id.';

-- ---------------------------------------------------------------------------
-- Admin access log for revealing search_audit.query_text
-- ---------------------------------------------------------------------------
-- search_audit itself comes in a later migration (Layer 1). This table is
-- separate so RTBF/DSAR purges of search_audit cannot also erase
-- accountability.
CREATE TABLE IF NOT EXISTS bedrock.search_audit_access_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
admin_email CITEXT NOT NULL,
target_user_email CITEXT NOT NULL,
revealed_query_text BOOLEAN NOT NULL,
reason TEXT NOT NULL,
request_id UUID NOT NULL,
org_id TEXT NOT NULL DEFAULT 'pursuit'
);

CREATE INDEX IF NOT EXISTS idx_search_audit_access_admin
ON bedrock.search_audit_access_log(admin_email, accessed_at DESC);
CREATE INDEX IF NOT EXISTS idx_search_audit_access_target
ON bedrock.search_audit_access_log(target_user_email, accessed_at DESC);

COMMENT ON TABLE bedrock.search_audit_access_log IS
'Append-only log of admins viewing other users search history with query_text revealed. Survives RTBF purges.';

-- ---------------------------------------------------------------------------
-- Grants — bedrock_user owns these tables and writes through the API.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN
GRANT SELECT, INSERT ON bedrock.pebble_write_audit TO bedrock_user;
GRANT SELECT, INSERT ON bedrock.search_audit_access_log TO bedrock_user;
-- UPDATE intentionally NOT granted on pebble_write_audit:
-- audit rows are append-only (immutability matters for forensics).
-- DELETE limited to a separate retention job role, added when
-- the 90d cleanup script lands.
END IF;
END $$;
Loading