Skip to content

Commit ade3980

Browse files
feat(auth): add Supabase org authz profile
Introduce an opt-in supabase_org deployment profile that models an organization as the Hindsight tenant. The dataplane now has built-in SupabaseOrgTenantExtension and SupabaseAuthorizationExtension classes sharing a policy resolver for Supabase JWTs and Hindsight scoped API keys. The resolver maps callers to organization schemas, member roles, bank scopes, operation scopes, and tenant config without changing the existing default, API key, or user-level Supabase tenant modes. Wire profile validation into API startup, uvicorn import startup, and worker startup so partial supabase_org deployments fail fast. Expose authz profile details through version features so the control plane can detect mismatched dataplane configuration instead of silently entering a half-working state. Add the control-plane supabase_org auth provider with login, signup, logout, selected-organization cookies, organization switching, team management, manual invite links, and Hindsight scoped API key management. Existing API wrappers now create request-scoped dataplane clients, forwarding the user's Supabase JWT and X-Hindsight-Org-Id in supabase_org mode while preserving fixed dataplane API key behavior for access_key and disabled modes. Add Supabase local-stack config and migrations for organizations, memberships, invites, API keys, and bank scopes. These authorization metadata tables live in public for PostgREST access but enable RLS and define no anon/authenticated policies, so browser clients must go through the control-plane wrapper APIs while server-side code uses the service role key. Cover the implementation with resolver and extension unit tests, control-plane auth/store/header-forwarding tests, a route scan that prevents app API routes from importing global dataplane clients, and a Supabase CLI integration test that exercises real Auth, PostgREST, RLS, JWT resolution, and scoped API key resolution. The integration CI job now installs a pinned Supabase CLI and also runs when control-plane changes touch the local Supabase project. Document the implemented V1 boundaries in code and tests: manual invites only, no password recovery UI, no organization schema deletion flow, no child API keys, no memory-level ACLs, and no cross-tenant sharing.
1 parent 44972d3 commit ade3980

114 files changed

Lines changed: 4497 additions & 198 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2877,6 +2877,55 @@ jobs:
28772877
echo "=== API Server Logs ==="
28782878
cat /tmp/api-server.log || echo "No API server log found"
28792879
2880+
test-supabase-org-authz-integration:
2881+
needs: [detect-changes]
2882+
if: >-
2883+
github.event_name == 'workflow_dispatch' ||
2884+
needs.detect-changes.outputs.control-plane == 'true' ||
2885+
needs.detect-changes.outputs.integration-tests == 'true' ||
2886+
needs.detect-changes.outputs.ci == 'true'
2887+
runs-on: ubuntu-latest
2888+
timeout-minutes: 30
2889+
2890+
steps:
2891+
- uses: actions/checkout@v6
2892+
with:
2893+
ref: ${{ github.event.pull_request.head.sha || '' }}
2894+
2895+
- name: Install uv
2896+
uses: astral-sh/setup-uv@v7
2897+
with:
2898+
enable-cache: true
2899+
prune-cache: false
2900+
2901+
- name: Set up Python
2902+
uses: actions/setup-python@v6
2903+
with:
2904+
python-version-file: ".python-version"
2905+
2906+
- name: Install Supabase CLI
2907+
uses: supabase/setup-cli@v2
2908+
with:
2909+
version: 2.84.2
2910+
2911+
- name: Verify Supabase CLI
2912+
run: supabase --version
2913+
2914+
- name: Verify Docker
2915+
run: docker version
2916+
2917+
- name: Install integration test dependencies
2918+
working-directory: ./hindsight-integration-tests
2919+
run: uv sync --frozen
2920+
2921+
- name: Install API dependencies
2922+
working-directory: ./hindsight-api-slim
2923+
run: uv sync --frozen --all-extras --index-strategy unsafe-best-match
2924+
2925+
- name: Run Supabase org authz integration test
2926+
working-directory: ./hindsight-integration-tests
2927+
run: uv run pytest tests/test_supabase_org_authz.py -v
2928+
28802929
test-integration:
28812930
needs: [detect-changes]
28822931
if: >-
@@ -4701,6 +4750,7 @@ jobs:
47014750
- test-rust-client
47024751
- test-go-client
47034752
- test-openclaw-integration
4753+
- test-supabase-org-authz-integration
47044754
- test-integration
47054755
- test-ag2-integration
47064756
- test-autogen-integration

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,5 @@ hindsight-integrations/_drafts/
6363

6464
blog-post*
6565
.worktrees/
66+
hindsight-control-plane/supabase/.branches/
67+
hindsight-control-plane/supabase/.temp/

hindsight-api-slim/hindsight_api/api/http.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2742,6 +2742,19 @@ class AsyncOperationSubmitResponse(BaseModel):
27422742
class FeaturesInfo(BaseModel):
27432743
"""Feature flags indicating which capabilities are enabled."""
27442744

2745+
authz_profile: str = Field(default="disabled", description="Configured authorization deployment profile")
2746+
tenant_extension: str | None = Field(default=None, description="Loaded tenant extension class name")
2747+
operation_validator_extension: str | None = Field(
2748+
default=None, description="Loaded operation validator extension class name"
2749+
)
2750+
supabase_org_ready: bool = Field(default=False, description="Whether the supabase_org profile is fully configured")
2751+
organization_features_enabled: bool = Field(
2752+
default=False, description="Whether organization management features are enabled"
2753+
)
2754+
team_features_enabled: bool = Field(default=False, description="Whether team management features are enabled")
2755+
cloud_api_keys_enabled: bool = Field(
2756+
default=False, description="Whether Cloud-like scoped API key management is enabled"
2757+
)
27452758
observations: bool = Field(description="Whether observations (auto-consolidation) are enabled")
27462759
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
27472760
worker: bool = Field(description="Whether the background worker is enabled")
@@ -3040,6 +3053,20 @@ async def lifespan(app: FastAPI):
30403053
logging.error(f"Failed to initialize tracing: {e}")
30413054
logging.warning("Continuing without tracing")
30423055

3056+
# Call tenant and validator startup hooks before memory.initialize().
3057+
# Startup migrations call tenant_extension.list_tenants(), so external
3058+
# tenant resolvers (for example Supabase-backed org discovery) must be
3059+
# connected before initialization starts.
3060+
tenant_extension = memory.tenant_extension
3061+
if tenant_extension:
3062+
await tenant_extension.on_startup()
3063+
logging.info("Tenant extension started")
3064+
3065+
operation_validator = getattr(memory, "_operation_validator", None)
3066+
if operation_validator:
3067+
await operation_validator.on_startup()
3068+
logging.info("Operation validator extension started")
3069+
30433070
# Startup: Initialize database and memory system (migrations run inside initialize if enabled)
30443071
if initialize_memory:
30453072
await memory.initialize()
@@ -3078,12 +3105,6 @@ async def lifespan(app: FastAPI):
30783105
"Tasks (mental model refresh, consolidation) will run synchronously."
30793106
)
30803107

3081-
# Call tenant extension startup hook (e.g. JWKS fetch for Supabase)
3082-
tenant_extension = memory.tenant_extension
3083-
if tenant_extension:
3084-
await tenant_extension.on_startup()
3085-
logging.info("Tenant extension started")
3086-
30873108
# Call HTTP extension startup hook
30883109
if http_extension:
30893110
await http_extension.on_startup()
@@ -3107,6 +3128,10 @@ async def lifespan(app: FastAPI):
31073128
await tenant_extension.on_shutdown()
31083129
logging.info("Tenant extension stopped")
31093130

3131+
if operation_validator:
3132+
await operation_validator.on_shutdown()
3133+
logging.info("Operation validator extension stopped")
3134+
31103135
# Call HTTP extension shutdown hook
31113136
if http_extension:
31123137
await http_extension.on_shutdown()
@@ -3292,7 +3317,10 @@ def _register_routes(app: FastAPI):
32923317
# Create audit decorator bound to this app's audit logger
32933318
audited = _make_audited_http(lambda: getattr(app.state, "audit_logger", None))
32943319

3295-
def get_request_context(authorization: str | None = Header(default=None)) -> RequestContext:
3320+
def get_request_context(
3321+
request: Request,
3322+
authorization: str | None = Header(default=None),
3323+
) -> RequestContext:
32963324
"""
32973325
Extract request context from Authorization header.
32983326
@@ -3308,7 +3336,10 @@ def get_request_context(authorization: str | None = Header(default=None)) -> Req
33083336
api_key = authorization[7:].strip()
33093337
else:
33103338
api_key = authorization.strip()
3311-
return RequestContext(api_key=api_key)
3339+
return RequestContext(
3340+
api_key=api_key,
3341+
selected_org_id=request.headers.get("x-hindsight-org-id"),
3342+
)
33123343

33133344
def precheck_for(operation: str):
33143345
"""
@@ -3421,11 +3452,23 @@ async def version_endpoint() -> VersionResponse:
34213452
"""
34223453
from hindsight_api import __version__
34233454
from hindsight_api.config import _get_raw_config
3455+
from hindsight_api.extensions.authz_profile import get_authz_profile_info
34243456

34253457
config = _get_raw_config()
3458+
authz_info = get_authz_profile_info(
3459+
app.state.memory.tenant_extension,
3460+
getattr(app.state.memory, "_operation_validator", None),
3461+
)
34263462
return VersionResponse(
34273463
api_version=__version__,
34283464
features=FeaturesInfo(
3465+
authz_profile=authz_info.authz_profile,
3466+
tenant_extension=authz_info.tenant_extension,
3467+
operation_validator_extension=authz_info.operation_validator_extension,
3468+
supabase_org_ready=authz_info.supabase_org_ready,
3469+
organization_features_enabled=authz_info.supabase_org_ready,
3470+
team_features_enabled=authz_info.supabase_org_ready,
3471+
cloud_api_keys_enabled=authz_info.supabase_org_ready,
34293472
observations=config.enable_observations,
34303473
mcp=config.mcp_enabled,
34313474
worker=config.worker_enabled,

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,9 @@ def __init__(
11431143
webhook_manager=None,
11441144
current_schema=None,
11451145
)
1146+
self._tenant_extension.set_context(self._ext_ctx)
1147+
if self._operation_validator is not None:
1148+
self._operation_validator.set_context(self._ext_ctx)
11461149

11471150
loaded = load_extension("MEMORY_DEFENSE", MemoryDefenseExtension, context=self._ext_ctx)
11481151
if loaded is not None:

hindsight-api-slim/hindsight_api/extensions/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from hindsight_api.extensions.builtin import (
2020
ApiKeyTenantExtension,
2121
MemoryDefenseRegexExtension,
22+
SupabaseAuthorizationExtension,
23+
SupabaseOrgTenantExtension,
2224
SupabaseTenantExtension,
2325
)
2426
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
@@ -111,6 +113,8 @@
111113
"MentalModelRefreshResult",
112114
# Tenant/Auth
113115
"ApiKeyTenantExtension",
116+
"SupabaseAuthorizationExtension",
117+
"SupabaseOrgTenantExtension",
114118
"SupabaseTenantExtension",
115119
"AuthenticationError",
116120
"RequestContext",
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Deployment profile validation for grouped authn/authz integrations."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from dataclasses import dataclass
7+
from typing import Any
8+
9+
ENV_AUTHZ_PROFILE = "HINDSIGHT_API_AUTHZ_PROFILE"
10+
SUPABASE_ORG_PROFILE = "supabase_org"
11+
SUPABASE_ORG_TENANT_CLASS = "SupabaseOrgTenantExtension"
12+
SUPABASE_ORG_VALIDATOR_CLASS = "SupabaseAuthorizationExtension"
13+
14+
15+
@dataclass(frozen=True)
16+
class AuthzProfileInfo:
17+
"""Authz deployment state exposed to control-plane health/version checks."""
18+
19+
authz_profile: str
20+
tenant_extension: str | None
21+
operation_validator_extension: str | None
22+
supabase_org_ready: bool
23+
24+
25+
def _class_name(instance: Any | None) -> str | None:
26+
return instance.__class__.__name__ if instance is not None else None
27+
28+
29+
def get_authz_profile_info(tenant_extension: Any | None, operation_validator: Any | None) -> AuthzProfileInfo:
30+
"""Return normalized authz profile information for diagnostics and feature gates."""
31+
32+
profile = os.getenv(ENV_AUTHZ_PROFILE, "disabled").strip() or "disabled"
33+
tenant_name = _class_name(tenant_extension)
34+
validator_name = _class_name(operation_validator)
35+
return AuthzProfileInfo(
36+
authz_profile=profile,
37+
tenant_extension=tenant_name,
38+
operation_validator_extension=validator_name,
39+
supabase_org_ready=(
40+
profile == SUPABASE_ORG_PROFILE
41+
and tenant_name == SUPABASE_ORG_TENANT_CLASS
42+
and validator_name == SUPABASE_ORG_VALIDATOR_CLASS
43+
),
44+
)
45+
46+
47+
def validate_authz_profile(tenant_extension: Any | None, operation_validator: Any | None) -> None:
48+
"""Fail fast when a deployment profile is only partially configured."""
49+
50+
info = get_authz_profile_info(tenant_extension, operation_validator)
51+
52+
has_supabase_org_piece = (
53+
info.tenant_extension == SUPABASE_ORG_TENANT_CLASS
54+
or info.operation_validator_extension == SUPABASE_ORG_VALIDATOR_CLASS
55+
)
56+
if info.authz_profile == SUPABASE_ORG_PROFILE:
57+
if not info.supabase_org_ready:
58+
raise RuntimeError(
59+
"HINDSIGHT_API_AUTHZ_PROFILE=supabase_org requires "
60+
"HINDSIGHT_API_TENANT_EXTENSION=...:SupabaseOrgTenantExtension and "
61+
"HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=...:SupabaseAuthorizationExtension. "
62+
f"Got tenant_extension={info.tenant_extension!r}, "
63+
f"operation_validator_extension={info.operation_validator_extension!r}."
64+
)
65+
return
66+
67+
if has_supabase_org_piece:
68+
raise RuntimeError(
69+
"Supabase org authz extensions are a grouped deployment profile. "
70+
"Set HINDSIGHT_API_AUTHZ_PROFILE=supabase_org and configure both "
71+
"SupabaseOrgTenantExtension and SupabaseAuthorizationExtension."
72+
)

hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@
77
Available built-in extensions:
88
- ApiKeyTenantExtension: Simple API key validation with public schema
99
- SupabaseTenantExtension: Supabase JWT validation with per-user schema isolation
10+
- SupabaseOrgTenantExtension: Supabase Auth with organization schema isolation
11+
- SupabaseAuthorizationExtension: Organization role/API-key bank-scope authorization
1012
1113
Example usage:
1214
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
1315
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
1416
"""
1517

1618
from hindsight_api.extensions.builtin.memory_defense_regex import MemoryDefenseRegexExtension
19+
from hindsight_api.extensions.builtin.supabase_org import SupabaseAuthorizationExtension, SupabaseOrgTenantExtension
1720
from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension
1821
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
1922

2023
__all__ = [
2124
"ApiKeyTenantExtension",
2225
"MemoryDefenseRegexExtension",
26+
"SupabaseAuthorizationExtension",
27+
"SupabaseOrgTenantExtension",
2328
"SupabaseTenantExtension",
2429
]

0 commit comments

Comments
 (0)