Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 50 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2877,6 +2877,55 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"

test-supabase-org-authz-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.control-plane == 'true' ||
needs.detect-changes.outputs.integration-tests == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"

- name: Install Supabase CLI
uses: supabase/setup-cli@v2
with:
version: 2.84.2

- name: Verify Supabase CLI
run: supabase --version

- name: Verify Docker
run: docker version

- name: Install integration test dependencies
working-directory: ./hindsight-integration-tests
run: uv sync --frozen

- name: Install API dependencies
working-directory: ./hindsight-api-slim
run: uv sync --frozen --all-extras --index-strategy unsafe-best-match

- name: Run Supabase org authz integration test
working-directory: ./hindsight-integration-tests
run: uv run pytest tests/test_supabase_org_authz.py -v

test-integration:
needs: [detect-changes]
if: >-
Expand Down Expand Up @@ -4701,6 +4750,7 @@ jobs:
- test-rust-client
- test-go-client
- test-openclaw-integration
- test-supabase-org-authz-integration
- test-integration
- test-ag2-integration
- test-autogen-integration
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,5 @@ hindsight-integrations/_drafts/

blog-post*
.worktrees/
hindsight-control-plane/supabase/.branches/
hindsight-control-plane/supabase/.temp/
49 changes: 41 additions & 8 deletions hindsight-api-slim/hindsight_api/api/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2742,6 +2742,12 @@ class AsyncOperationSubmitResponse(BaseModel):
class FeaturesInfo(BaseModel):
"""Feature flags indicating which capabilities are enabled."""

authz_profile: str = Field(default="disabled", description="Configured authorization deployment profile")
tenant_extension: str | None = Field(default=None, description="Loaded tenant extension class name")
operation_validator_extension: str | None = Field(
default=None, description="Loaded operation validator extension class name"
)
supabase_org_ready: bool = Field(default=False, description="Whether the supabase_org profile is fully configured")
observations: bool = Field(description="Whether observations (auto-consolidation) are enabled")
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: bool = Field(description="Whether the background worker is enabled")
Expand Down Expand Up @@ -3040,6 +3046,20 @@ async def lifespan(app: FastAPI):
logging.error(f"Failed to initialize tracing: {e}")
logging.warning("Continuing without tracing")

# Call tenant and validator startup hooks before memory.initialize().
# Startup migrations call tenant_extension.list_tenants(), so external
# tenant resolvers (for example Supabase-backed org discovery) must be
# connected before initialization starts.
tenant_extension = memory.tenant_extension
if tenant_extension:
await tenant_extension.on_startup()
logging.info("Tenant extension started")

operation_validator = getattr(memory, "_operation_validator", None)
if operation_validator:
await operation_validator.on_startup()
logging.info("Operation validator extension started")

# Startup: Initialize database and memory system (migrations run inside initialize if enabled)
if initialize_memory:
await memory.initialize()
Expand Down Expand Up @@ -3078,12 +3098,6 @@ async def lifespan(app: FastAPI):
"Tasks (mental model refresh, consolidation) will run synchronously."
)

# Call tenant extension startup hook (e.g. JWKS fetch for Supabase)
tenant_extension = memory.tenant_extension
if tenant_extension:
await tenant_extension.on_startup()
logging.info("Tenant extension started")

# Call HTTP extension startup hook
if http_extension:
await http_extension.on_startup()
Expand All @@ -3107,6 +3121,10 @@ async def lifespan(app: FastAPI):
await tenant_extension.on_shutdown()
logging.info("Tenant extension stopped")

if operation_validator:
await operation_validator.on_shutdown()
logging.info("Operation validator extension stopped")

# Call HTTP extension shutdown hook
if http_extension:
await http_extension.on_shutdown()
Expand Down Expand Up @@ -3292,7 +3310,10 @@ def _register_routes(app: FastAPI):
# Create audit decorator bound to this app's audit logger
audited = _make_audited_http(lambda: getattr(app.state, "audit_logger", None))

def get_request_context(authorization: str | None = Header(default=None)) -> RequestContext:
def get_request_context(
request: Request,
authorization: str | None = Header(default=None),
) -> RequestContext:
"""
Extract request context from Authorization header.

Expand All @@ -3308,7 +3329,10 @@ def get_request_context(authorization: str | None = Header(default=None)) -> Req
api_key = authorization[7:].strip()
else:
api_key = authorization.strip()
return RequestContext(api_key=api_key)
return RequestContext(
api_key=api_key,
selected_org_id=request.headers.get("x-hindsight-org-id"),
)

def precheck_for(operation: str):
"""
Expand Down Expand Up @@ -3421,11 +3445,20 @@ async def version_endpoint() -> VersionResponse:
"""
from hindsight_api import __version__
from hindsight_api.config import _get_raw_config
from hindsight_api.extensions.authz_profile import get_authz_profile_info

config = _get_raw_config()
authz_info = get_authz_profile_info(
app.state.memory.tenant_extension,
getattr(app.state.memory, "_operation_validator", None),
)
return VersionResponse(
api_version=__version__,
features=FeaturesInfo(
authz_profile=authz_info.authz_profile,
tenant_extension=authz_info.tenant_extension,
operation_validator_extension=authz_info.operation_validator_extension,
supabase_org_ready=authz_info.supabase_org_ready,
observations=config.enable_observations,
mcp=config.mcp_enabled,
worker=config.worker_enabled,
Expand Down
3 changes: 3 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/memory_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,9 @@ def __init__(
webhook_manager=None,
current_schema=None,
)
self._tenant_extension.set_context(self._ext_ctx)
if self._operation_validator is not None:
self._operation_validator.set_context(self._ext_ctx)

loaded = load_extension("MEMORY_DEFENSE", MemoryDefenseExtension, context=self._ext_ctx)
if loaded is not None:
Expand Down
4 changes: 4 additions & 0 deletions hindsight-api-slim/hindsight_api/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from hindsight_api.extensions.builtin import (
ApiKeyTenantExtension,
MemoryDefenseRegexExtension,
SupabaseAuthorizationExtension,
SupabaseOrgTenantExtension,
SupabaseTenantExtension,
)
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
Expand Down Expand Up @@ -111,6 +113,8 @@
"MentalModelRefreshResult",
# Tenant/Auth
"ApiKeyTenantExtension",
"SupabaseAuthorizationExtension",
"SupabaseOrgTenantExtension",
"SupabaseTenantExtension",
"AuthenticationError",
"RequestContext",
Expand Down
72 changes: 72 additions & 0 deletions hindsight-api-slim/hindsight_api/extensions/authz_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Deployment profile validation for grouped authn/authz integrations."""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Any

ENV_AUTHZ_PROFILE = "HINDSIGHT_API_AUTHZ_PROFILE"
SUPABASE_ORG_PROFILE = "supabase_org"
SUPABASE_ORG_TENANT_CLASS = "SupabaseOrgTenantExtension"
SUPABASE_ORG_VALIDATOR_CLASS = "SupabaseAuthorizationExtension"


@dataclass(frozen=True)
class AuthzProfileInfo:
"""Authz deployment state exposed to control-plane health/version checks."""

authz_profile: str
tenant_extension: str | None
operation_validator_extension: str | None
supabase_org_ready: bool


def _class_name(instance: Any | None) -> str | None:
return instance.__class__.__name__ if instance is not None else None


def get_authz_profile_info(tenant_extension: Any | None, operation_validator: Any | None) -> AuthzProfileInfo:
"""Return normalized authz profile information for diagnostics and feature gates."""

profile = os.getenv(ENV_AUTHZ_PROFILE, "disabled").strip() or "disabled"
tenant_name = _class_name(tenant_extension)
validator_name = _class_name(operation_validator)
return AuthzProfileInfo(
authz_profile=profile,
tenant_extension=tenant_name,
operation_validator_extension=validator_name,
supabase_org_ready=(
profile == SUPABASE_ORG_PROFILE
and tenant_name == SUPABASE_ORG_TENANT_CLASS
and validator_name == SUPABASE_ORG_VALIDATOR_CLASS
),
)


def validate_authz_profile(tenant_extension: Any | None, operation_validator: Any | None) -> None:
"""Fail fast when a deployment profile is only partially configured."""

info = get_authz_profile_info(tenant_extension, operation_validator)

has_supabase_org_piece = (
info.tenant_extension == SUPABASE_ORG_TENANT_CLASS
or info.operation_validator_extension == SUPABASE_ORG_VALIDATOR_CLASS
)
if info.authz_profile == SUPABASE_ORG_PROFILE:
if not info.supabase_org_ready:
raise RuntimeError(
"HINDSIGHT_API_AUTHZ_PROFILE=supabase_org requires "
"HINDSIGHT_API_TENANT_EXTENSION=...:SupabaseOrgTenantExtension and "
"HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=...:SupabaseAuthorizationExtension. "
f"Got tenant_extension={info.tenant_extension!r}, "
f"operation_validator_extension={info.operation_validator_extension!r}."
)
return

if has_supabase_org_piece:
raise RuntimeError(
"Supabase org authz extensions are a grouped deployment profile. "
"Set HINDSIGHT_API_AUTHZ_PROFILE=supabase_org and configure both "
"SupabaseOrgTenantExtension and SupabaseAuthorizationExtension."
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@
Available built-in extensions:
- ApiKeyTenantExtension: Simple API key validation with public schema
- SupabaseTenantExtension: Supabase JWT validation with per-user schema isolation
- SupabaseOrgTenantExtension: Supabase Auth with organization schema isolation
- SupabaseAuthorizationExtension: Organization role/API-key bank-scope authorization

Example usage:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
"""

from hindsight_api.extensions.builtin.memory_defense_regex import MemoryDefenseRegexExtension
from hindsight_api.extensions.builtin.supabase_org import SupabaseAuthorizationExtension, SupabaseOrgTenantExtension
from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension

__all__ = [
"ApiKeyTenantExtension",
"MemoryDefenseRegexExtension",
"SupabaseAuthorizationExtension",
"SupabaseOrgTenantExtension",
"SupabaseTenantExtension",
]
Loading