diff --git a/.claude/agents/backend-engineer.md b/.claude/agents/backend-engineer.md index dbcc18d..d0eb97e 100644 --- a/.claude/agents/backend-engineer.md +++ b/.claude/agents/backend-engineer.md @@ -12,7 +12,7 @@ You are a **backend engineer** for FuzeFront. You implement the **backend slice ## Your scope (and ONLY this) HTTP API + services + business logic + DB schema/migrations + event producers/consumers + the backend's **own unit/integration tests**. Implement against the **frozen API contract** (OpenAPI + event schemas) — consume/produce the generated `@fuzefront/-client` types; if the contract is wrong, amend the contract PR, don't diverge. -**Plan with feature flags (`feature-flags` skill).** Wrap **new or risky** server logic in a flag, **default OFF** (a release flag) — merge dark, release deliberately, and keep a kill-switch (**default ON**) on expensive/risky paths. Read flags via the `@fuzefront/feature-flags` client (OpenFeature API), passing the standard evaluation context (environment + org/tenant + user + app); never hand-wire Unleash/OpenFeature. **Test BOTH states** (off-path and on-path) in your unit/integration tests. A **permission** flag is rollout convenience only — it never replaces a `permit.check` (real authz stays in Permit). Record each flag's owner + removal criterion and **retire stale flags** in a cleanup PR. To create or change a flag's type/targeting/lifecycle, that's `feature-flags-engineer` — you consume the flag and wrap your code; you don't administer the flag platform. +**Plan with feature flags (`feature-flags` skill).** Wrap **new or risky** server logic in a flag, **default OFF** (a release flag) — merge dark, release deliberately, and keep a kill-switch (**default ON**) on expensive/risky paths. Read flags via the `@fuzefront/feature-flags` client (OpenFeature API), passing the standard evaluation context (environment + org/tenant + user + app); never hand-wire Unleash/OpenFeature. **Test BOTH states** (off-path and on-path) in your unit/integration tests. A **permission** flag is rollout convenience only — it never replaces a real authorization check (real authz is FuzeFront's `POST /v1/security/authz/check`, reached via `app.security.require_permission`; FuzeKeys never talks to a policy engine directly). Record each flag's owner + removal criterion and **retire stale flags** in a cleanup PR. To create or change a flag's type/targeting/lifecycle, that's `feature-flags-engineer` — you consume the flag and wrap your code; you don't administer the flag platform. **Pagination is mandatory on every unbounded collection endpoint** (baseline §4.1 / `governance/pagination-standard.md`, enforced by `gate-pagination`). Any LIST/collection GET you implement MUST: accept `limit` (apply the contract's default + **enforce the max server-side**, clamping over-max requests) and `cursor` (preferred — opaque, server-issued, encoding sort-key + tiebreaker) or `offset`; return the envelope `{ items, page: { nextCursor|null, hasMore, total? } }`; and walk the full set deterministically (no gaps/dupes under concurrent writes). **Your unit tests assert** the limit clamp, the envelope shape, and that the cursor pages through correctly. An endpoint is exempt only if inherently bounded/singleton and so annotated in the contract (`x-pagination: exempt`). diff --git a/.github/workflows/nightly-integration.yml b/.github/workflows/nightly-integration.yml index ca6735c..a53c2d6 100644 --- a/.github/workflows/nightly-integration.yml +++ b/.github/workflows/nightly-integration.yml @@ -104,7 +104,7 @@ jobs: gh issue create --repo "$GITHUB_REPOSITORY" --label integration --title "$title" --body "$(cat <<'EOF' @claude — this repo has no nightly integration suite / bounded local-up yet, so **Nightly Integration** is a no-op here. Please build it (contract-first), per the FuzeSDLC standard, landing a **DRAFT PR** (do not merge): - - **devops-engineer** — vendor FuzeInfra as a submodule; wire `docker-compose.consumer-test.yml` + `versions.env` + the external-service mock matrix (MailHog / Twilio-mock / Permit-offline / Stripe-test / Prism-MSW / LocalStack); enforce no prod egress. + - **devops-engineer** — vendor FuzeInfra as a submodule; wire `docker-compose.consumer-test.yml` + `versions.env` + the external-service mock matrix (MailHog / Twilio-mock / FuzeFront-security-stub / Stripe-test / Prism-MSW / LocalStack); enforce no prod egress. - **test-engineer** — author the integration/contract suite against that bounded stack; expose it as `test:integration` (npm) or `pytest -m integration`, or declare `integrationTest` in `.fuze/manifest.json`. - **local-env-verifier** — verify the boundary (stack stands up, mocks are hit, no real external host contacted). diff --git a/.semgrep/fuze-authz.yml b/.semgrep/fuze-authz.yml index a1418c2..6246fa7 100644 --- a/.semgrep/fuze-authz.yml +++ b/.semgrep/fuze-authz.yml @@ -1,11 +1,13 @@ # Fuze appsec/authz seed rules for gate-authz. High-signal BOLA/IDOR heuristics; +# Messages are provider-neutral on purpose: FuzeKeys knows only the FuzeFront +# Security API, never which identity provider or policy engine sits behind it. # appsec-reviewer adjudicates, security ratchets to ERROR/required per repo once tuned. # The gate-authz job also runs registry packs (p/owasp-top-ten, p/secrets). rules: - id: fuze-bola-object-by-id-no-ownership languages: [javascript, typescript] severity: WARNING - message: "Resource loaded by client-supplied id. Confirm an ownership/permission check (permit.check / requireOwnership) gates this BEFORE it is returned or mutated (BOLA/IDOR - governance/architecture-guidelines.md section 2)." + message: "Resource loaded by client-supplied id. Confirm an ownership/permission check gates this BEFORE it is returned or mutated — the authorization decision comes from FuzeFront (POST /v1/security/authz/check, e.g. app.security.require_permission), never from a policy engine wired up here (BOLA/IDOR - governance/architecture-guidelines.md section 2)." pattern-either: - pattern: "$M.findById(req.params.$X)" - pattern: "$M.findByPk(req.params.$X)" @@ -24,7 +26,7 @@ rules: - id: fuze-auth-self-minted-user-token languages: [javascript, typescript, python] severity: WARNING - message: "Minting an auth/session token locally? AuthN is provided by FuzeFront (Authentik OIDC SSO) — products VERIFY FuzeFront-issued tokens, they do not issue their own user tokens. If this signs a user/session JWT, replace it with FuzeFront token verification (@fuzefront/auth). Service-to-service tokens are exempt. See governance/architecture-guidelines.md section 1 (auth via FuzeFront)." + message: "Minting an auth/session token locally? AuthN is provided by the FuzeFront Security API — products VERIFY FuzeFront-issued sessions, they do not issue their own user tokens. If this signs a user/session JWT, replace it with a GET /v1/security/session call (app.security.require_identity). Service-to-service and RFC 8693 broker tokens are exempt. See governance/architecture-guidelines.md section 1 (auth via FuzeFront)." pattern-either: - pattern: "jwt.sign(...)" - pattern: "jsonwebtoken.sign(...)" @@ -34,7 +36,7 @@ rules: - id: fuze-auth-local-password-store languages: [javascript, typescript, python] severity: WARNING - message: "Local password authentication detected. AuthN is FuzeFront's (Authentik OIDC) — products do not store or verify user passwords. Remove local login/password handling and verify FuzeFront-issued identity tokens instead. See governance/architecture-guidelines.md section 1 (auth via FuzeFront)." + message: "Local password authentication detected. AuthN belongs to the FuzeFront Security API — products do not store or verify user passwords. Remove local login/password handling and resolve the caller via GET /v1/security/session instead. NOTE: the FuzeKeys vault master key is NOT a password — it is a domain secret that decrypts the vault and is verified in app/utils/encryption.py. See governance/architecture-guidelines.md section 1 (auth via FuzeFront)." pattern-either: - pattern: "bcrypt.compare(...)" - pattern: "bcrypt.hash(...)" diff --git a/CLAUDE.md b/CLAUDE.md index 4e6ee3b..6a1f07a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,20 @@ FuzeKeys is a **keys / secrets / PII-tokenization product** — an intelligent i - **PII Tokenizer** (`pii-tokenizer/`): Vault-encrypted, Redis-stored tokens with a 24h TTL; enforced at a LiteLLM `pre_call` guardrail and Claude Code Pre/PostToolUse hooks. Preserve the **tokenize-before-send** and Vault-encryption invariants on every change; never log raw PII or widen what reaches an LLM provider. - `modules/` vendors `FuzeInfra` / `FuzeFront` / `EnvManager` as submodules — infra changes are delegated to FuzeInfra via `@claude`, never made from here. +## Auth — FuzeKeys authenticates nobody + +**AuthN and AuthZ are delegated wholly to the FuzeFront Security API.** FuzeKeys holds no session signing key, stores no user password, and evaluates no policy. Which identity provider or authorization engine sits behind that API is FuzeFront's private implementation detail and **must never be named or reached from this repo** — not in code, not in config, not in a Helm value. + +- **Backend seam:** `backend/app/security/` (`require_identity`, `get_current_user`, `require_permission`). `get_current_user` still returns the local `User` row so every FK keeps working — that row is a *projection* of a FuzeFront subject, not a credential store. +- **Frontend seam:** `frontend/src/services/securityClient.ts`, on the **same-origin** base (`/v1/security/*`). Never an absolute host — it breaks under local TLS and pins the app to one environment. +- **Authorization** uses `POST /v1/security/authz/check` (or `/bulk-check`) with the **bare** resource/action keys from `registration/policy.json` (`Identity:read`, `VaultAsset:reveal`, …). Never an engine-specific policy identifier. +- **Everything fails closed.** Unreachable service, unparseable body, absent tenant, mismatched bulk-decision length → deny. An authorization layer that opens when it breaks is worse than none, because it is trusted. +- **Do not invent endpoints.** If FuzeKeys needs something the security contract (`packages/security/openapi.yaml` in FuzeFront) has no operation for, that is a **contract gap to raise with FuzeFront** — never a direct call to some other system. + +**The vault master key is NOT authentication.** It is FuzeKeys' domain secret: it decrypts the vault, FuzeFront never sees it, and it is verified in `app/utils/encryption.py` behind `/api/v1/auth/vault/{setup,unlock}`. It used to ride along on the login form; that was a conflation, not a design. `bcrypt` here is for that key alone. + +**`permit.io` in `backend/app/integrations/site/` is a TARGET SITE, not auth.** FuzeKeys automates account creation on third-party SaaS sites; `permit.io` is one of them, exactly like `google.com`. Those references are product domain. Deleting them removes a capability. + ## Hardening & delivery (repo-specifics) - This repo is **already hardened** — the active "Protect default branch" ruleset, Harden Gate, signed commits, the standard automation stack, and nightly reconciliation are in place. Don't re-apply them. - **`deployOnPush: false`** — no deploy-on-push on this repo. Prod is GitOps; never hand-deploy to prod. diff --git a/README.md b/README.md index c4c8f31..b9a8556 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,12 @@ PORT=8002 - **End-to-End Encryption**: All sensitive data is encrypted before storage - **Master Key Control**: Users control their own encryption keys -- **Secure Authentication**: JWT-based authentication with bcrypt password hashing +- **Delegated Authentication**: sign-in, sign-up, MFA and session revocation are + handled by the FuzeFront Security API (`/v1/security/*`). FuzeKeys stores no + user password and mints no session token of its own. +- **Delegated Authorization**: every decision comes from + `POST /v1/security/authz/check` using the bare resource/action keys in + `registration/policy.json`. Fail-closed on any error. - **SQL Injection Protection**: Parameterized queries and ORM protection - **CORS Configuration**: Properly configured cross-origin resource sharing diff --git a/backend/alembic/versions/a6c3d1e45f23_fuzefront_security_migration.py b/backend/alembic/versions/a6c3d1e45f23_fuzefront_security_migration.py new file mode 100644 index 0000000..9bcf216 --- /dev/null +++ b/backend/alembic/versions/a6c3d1e45f23_fuzefront_security_migration.py @@ -0,0 +1,71 @@ +"""Migrate users off local password auth onto FuzeFront Security. + +Revision ID: a6c3d1e45f23 +Revises: f5b2c0d34e12 +Create Date: 2026-08-02 + +FuzeKeys no longer authenticates anybody. Identity comes from the FuzeFront +Security API (`GET /v1/security/session`), so the `users` row becomes a local +PROJECTION of a FuzeFront subject rather than a credential store: + + + fuzefront_user_id the stable FuzeFront subject id (`Identity.userId`), + nullable so existing rows can be adopted by email on + their owner's first FuzeFront-authenticated request + - hashed_password DROPPED. Storing user passwords is precisely the + coupling this migration removes; leaving the column + would leave the capability one commit away from + returning, and leave real password hashes at rest for a + login path that no longer exists. + ~ master_key_hash now nullable. It is FuzeKeys DOMAIN state (the vault + key verifier), not a login factor. It used to be + populated during local signup; a user provisioned from + a FuzeFront session has not set up their vault yet and + does so via POST /api/v1/auth/vault/setup. + +Downgrade restores the columns structurally but CANNOT restore password +hashes — they are intentionally destroyed. Anyone downgrading must re-enrol +local passwords, which is the point. +""" + +import sqlalchemy as sa +from alembic import op + +revision = "a6c3d1e45f23" +down_revision = "f5b2c0d34e12" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("users") as batch: + batch.add_column( + sa.Column("fuzefront_user_id", sa.String(length=255), nullable=True) + ) + batch.alter_column( + "master_key_hash", + existing_type=sa.String(length=255), + nullable=True, + ) + batch.drop_column("hashed_password") + + op.create_index( + "ix_users_fuzefront_user_id", + "users", + ["fuzefront_user_id"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_users_fuzefront_user_id", table_name="users") + + with op.batch_alter_table("users") as batch: + # Re-added as nullable: the original hashes are gone for good, and a + # NOT NULL column with no value to put in it cannot be created. + batch.add_column( + sa.Column("hashed_password", sa.String(length=255), nullable=True) + ) + batch.drop_column("fuzefront_user_id") + # master_key_hash is deliberately left nullable: rows provisioned after + # the upgrade may legitimately have no vault yet, so tightening it back + # to NOT NULL would fail. diff --git a/backend/app/database.py b/backend/app/database.py index 6637f62..b5079f8 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -84,21 +84,25 @@ async def init_database(): # Add sample data for demo async with async_session_maker() as session: from app.models import User, Identity, Account, AccountStage, StageType, StageStatus - from app.utils.encryption import EncryptionManager, hash_password, generate_master_key_hash + from app.utils.encryption import EncryptionManager, generate_master_key_hash import hashlib from datetime import datetime - + # Check if data already exists existing_user = await session.get(User, 1) if existing_user: print("✅ Database already initialized with sample data") return - - # Create demo user + + # Create demo user. + # No password: FuzeKeys does not authenticate anybody — the + # FuzeFront Security API does. The demo row is linked to a + # placeholder FuzeFront subject so `resolve_local_user` can adopt + # it; only the vault master key (domain state) is seeded here. demo_user = User( + fuzefront_user_id="demo-fuzefront-subject", email="demo@fuzekeys.io", username="demo_user", - hashed_password=hash_password("demo123"), master_key_hash=generate_master_key_hash("masterkey123"), is_active=True ) diff --git a/backend/app/main.py b/backend/app/main.py index ca06382..814d173 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -475,7 +475,15 @@ async def get_site(site_id: int): app.include_router(sites_router, tags=["Sites Management"]) -@app.get("/", +@app.on_event("shutdown") +async def _close_security_client(): + """Release the pooled connection to the FuzeFront Security API.""" + from app.security import close_security_client + + await close_security_client() + + +@app.get("/", summary="API Health Check", description="Root endpoint that returns API status and basic information", response_description="API status and version information") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1fecbeb..075bda4 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -5,16 +5,38 @@ class User(Base): - """User model for authentication and user management.""" - + """Local projection of a FuzeFront-authenticated principal. + + This is NOT a credential store. FuzeKeys does not authenticate anybody — + the FuzeFront Security API does, and `app.security` resolves the caller's + identity from it. This row exists so the integer `id` that every FuzeKeys + foreign key points at (identities, accounts, vault assets) keeps working, + and so profile fields render without a round trip per request. + + Deliberately absent: any password. It was removed in the FuzeFront Security + migration — a product that stores user passwords has taken ownership of + authentication, which is exactly what this repo must not do. + + `master_key_hash` stays. It is FuzeKeys' DOMAIN secret: the user-held key + that unlocks the encrypted vault. It is not a login factor, and FuzeFront + never sees it. + """ + __tablename__ = "users" - + id = Column(Integer, primary_key=True, index=True) + + # Stable FuzeFront subject id (`Identity.userId`) — the real identity key. + # Nullable so a pre-migration row can be adopted on first sign-in. + fuzefront_user_id = Column(String(255), unique=True, index=True, nullable=True) + email = Column(String(255), unique=True, index=True, nullable=False) username = Column(String(100), unique=True, index=True, nullable=False) - hashed_password = Column(String(255), nullable=False) - master_key_hash = Column(String(255), nullable=False) # For encrypting sensitive data - + + # Vault master-key verifier (FuzeKeys domain, NOT authentication). + # Nullable: a freshly-provisioned user has not set up their vault yet. + master_key_hash = Column(String(255), nullable=True) + # Profile information first_name = Column(String(100)) last_name = Column(String(100)) diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index 6ce5cbb..c13e2d7 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -11,12 +11,20 @@ from app.models.account import Account, AccountStage, StageType, StageStatus from app.models.identity import Identity from app.routers.auth import get_current_user +from app.security import require_permission from app.utils.logging import get_logger from app.utils.encryption import encrypt_field, decrypt_field logger = get_logger(__name__) router = APIRouter() +# Authorization is decided by FuzeFront (`POST /v1/security/authz/check`) using +# the BARE resource/action keys FuzeKeys declares in `registration/policy.json`. +# Runs in addition to the per-row ownership filter, not instead of it. +_CAN_READ = Depends(require_permission("Account", "read")) +_CAN_CREATE = Depends(require_permission("Account", "create")) +_CAN_UPDATE = Depends(require_permission("Account", "update")) + class StageStatusResponse(BaseModel): stage_type: str @@ -92,7 +100,7 @@ def get_default_stages_for_site(website_name: str) -> List[Dict]: return result -@router.get("/", response_model=List[AccountResponse]) +@router.get("/", response_model=List[AccountResponse], dependencies=[_CAN_READ]) async def list_accounts( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) @@ -145,7 +153,7 @@ async def list_accounts( ) -@router.post("/", response_model=AccountResponse) +@router.post("/", response_model=AccountResponse, dependencies=[_CAN_CREATE]) async def create_account( account_data: AccountCreate, current_user: User = Depends(get_current_user), @@ -255,7 +263,7 @@ async def create_account( ) -@router.patch("/{account_id}/stages/{stage_id}") +@router.patch("/{account_id}/stages/{stage_id}", dependencies=[_CAN_UPDATE]) async def update_account_stage( account_id: int, stage_id: int, @@ -346,7 +354,7 @@ async def update_account_stage( return result -@router.get("/", response_model=List[AccountResponse]) +@router.get("/", response_model=List[AccountResponse], dependencies=[_CAN_READ]) async def list_accounts( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) @@ -399,7 +407,7 @@ async def list_accounts( ) -@router.post("/", response_model=AccountResponse) +@router.post("/", response_model=AccountResponse, dependencies=[_CAN_CREATE]) async def create_account( account_data: AccountCreate, current_user: User = Depends(get_current_user), @@ -509,7 +517,7 @@ async def create_account( ) -@router.patch("/{account_id}/stages/{stage_id}") +@router.patch("/{account_id}/stages/{stage_id}", dependencies=[_CAN_UPDATE]) async def update_account_stage( account_id: int, stage_id: int, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 6c6e748..eb8d197 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,286 +1,208 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from datetime import datetime, timedelta -from typing import Optional -import os -from jose import JWTError, jwt -from pydantic import BaseModel, EmailStr - -from app.database import get_db -from app.models.user import User -from app.utils.encryption import ( - hash_password, verify_password, generate_master_key_hash, - verify_master_key, set_global_encryption_manager -) -from app.utils.logging import get_logger, log_security_event - -logger = get_logger(__name__) -router = APIRouter() -security = HTTPBearer() - -# JWT Configuration -# SECURITY (MEDIUM-2 / appsec #18): the JWT signing key must come from the -# environment with NO insecure default. The previous default ("your-secret-key") -# made tokens forgeable by anyone who knew the public source, which undermines -# EVERY `get_current_user` object-level check downstream. We fail CLOSED: -# - A blank/unset SECRET_KEY leaves the module importable (other routers import -# this module at startup) but `_require_secret_key()` raises HTTP 503 at -# request time on any token issue/verify, so no token is ever signed or -# accepted with an absent/weak key. -# - In a non-test environment we additionally reject the known-insecure legacy -# placeholder value so it can never be reintroduced via env. -SECRET_KEY = os.getenv("SECRET_KEY") -ALGORITHM = os.getenv("ALGORITHM", "HS256") -ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) - -# Known-insecure placeholder values that must never be used to sign/verify tokens. -_INSECURE_SECRET_VALUES = {"your-secret-key", "your-super-secret-key-here-change-this-in-production"} - -if not SECRET_KEY or not SECRET_KEY.strip(): - logger.warning( - "SECRET_KEY is not set. JWT issuance/verification will fail closed with " - "HTTP 503 until a strong SECRET_KEY is configured. No insecure default is used." - ) -elif SECRET_KEY.strip() in _INSECURE_SECRET_VALUES: - logger.error( - "SECRET_KEY is set to a known-insecure placeholder value. JWT issuance/" - "verification will fail closed with HTTP 503 until a strong SECRET_KEY is " - "configured." - ) - - -def _require_secret_key() -> str: - """Return a usable JWT signing key or fail closed. - - SECURITY: raises HTTPException(503) when SECRET_KEY is missing/blank or is the - known-insecure placeholder, so tokens can never be signed or validated with a - forgeable key. Every create/verify path routes through here. - """ - key = SECRET_KEY - if not key or not key.strip() or key.strip() in _INSECURE_SECRET_VALUES: - logger.error("Refusing JWT operation: SECRET_KEY is unset, blank, or insecure.") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Authentication is not configured", - ) - return key.strip() - - -# Pydantic models -class UserCreate(BaseModel): - username: str - email: EmailStr - password: str - master_key: str - first_name: Optional[str] = None - last_name: Optional[str] = None - - -class UserLogin(BaseModel): - email: EmailStr - password: str - master_key: str - - -class Token(BaseModel): - access_token: str - token_type: str - - -class UserResponse(BaseModel): - id: int - username: str - email: str - first_name: Optional[str] - last_name: Optional[str] - is_active: bool - is_verified: bool - created_at: datetime - - -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - """Create JWT access token.""" - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, _require_secret_key(), algorithm=ALGORITHM) - return encoded_jwt - - -async def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), - db: AsyncSession = Depends(get_db) -) -> User: - """Get current authenticated user.""" - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - payload = jwt.decode(credentials.credentials, _require_secret_key(), algorithms=[ALGORITHM]) - user_id: int = payload.get("sub") - if user_id is None: - raise credentials_exception - except JWTError: - raise credentials_exception - - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - if user is None: - raise credentials_exception - - return user - - -@router.post("/register", response_model=UserResponse) -async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): - """Register a new user.""" - try: - # Check if user already exists - result = await db.execute( - select(User).where( - (User.email == user_data.email) | (User.username == user_data.username) - ) - ) - existing_user = result.scalar_one_or_none() - - if existing_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="User with this email or username already exists" - ) - - # Create new user - hashed_password = hash_password(user_data.password) - master_key_hash = generate_master_key_hash(user_data.master_key) - - new_user = User( - username=user_data.username, - email=user_data.email, - hashed_password=hashed_password, - master_key_hash=master_key_hash, - first_name=user_data.first_name, - last_name=user_data.last_name, - ) - - db.add(new_user) - await db.commit() - await db.refresh(new_user) - - log_security_event("user_registered", user_id=new_user.id, - details={"email": user_data.email, "username": user_data.username}) - - return UserResponse( - id=new_user.id, - username=new_user.username, - email=new_user.email, - first_name=new_user.first_name, - last_name=new_user.last_name, - is_active=new_user.is_active, - is_verified=new_user.is_verified, - created_at=new_user.created_at - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error registering user: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - ) - - -@router.post("/login", response_model=Token) -async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)): - """Authenticate user and return access token.""" - try: - # Find user by email - result = await db.execute(select(User).where(User.email == user_data.email)) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password" - ) - - # Verify password - if not verify_password(user_data.password, user.hashed_password): - log_security_event("failed_login_attempt", user_id=user.id, - details={"reason": "invalid_password"}) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password" - ) - - # Verify master key - if not verify_master_key(user_data.master_key, user.master_key_hash): - log_security_event("failed_login_attempt", user_id=user.id, - details={"reason": "invalid_master_key"}) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect master key" - ) - - # Check if user is active - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Account is deactivated" - ) - - # Set up encryption manager for this session - set_global_encryption_manager(user_data.master_key) - - # Update last login - user.last_login = datetime.utcnow() - await db.commit() - - # Create access token - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": str(user.id)}, expires_delta=access_token_expires - ) - - log_security_event("successful_login", user_id=user.id) - - return {"access_token": access_token, "token_type": "bearer"} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error during login: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" - ) - - -@router.get("/me", response_model=UserResponse) -async def get_current_user_info(current_user: User = Depends(get_current_user)): - """Get current user information.""" - return UserResponse( - id=current_user.id, - username=current_user.username, - email=current_user.email, - first_name=current_user.first_name, - last_name=current_user.last_name, - is_active=current_user.is_active, - is_verified=current_user.is_verified, - created_at=current_user.created_at - ) - - -@router.post("/logout") -async def logout(): - """Logout user (client should discard token).""" - # In a more sophisticated setup, you might want to blacklist the token - return {"message": "Successfully logged out"} \ No newline at end of file +"""Session + vault-unlock routes. + +FuzeKeys does NOT authenticate users. Authentication — password login, social +login, signup, MFA, password reset, session revocation — is owned end-to-end by +the FuzeFront Security API, which the frontend calls directly on the +same-origin API base. This router therefore no longer exposes `/login` or +`/register`; there is nothing left here to log in *to*. + +What remains is the seam, plus the one thing that is genuinely FuzeKeys': + + GET /api/v1/auth/me the caller's identity, resolved via FuzeFront + POST /api/v1/auth/logout delegates to `DELETE /v1/security/session` + GET /api/v1/auth/vault vault (master-key) status for this user + POST /api/v1/auth/vault/setup set the vault master key the first time + POST /api/v1/auth/vault/unlock unlock the vault for this process + +The master key is a DOMAIN secret, not a login factor. Before this migration it +was smuggled into the login request, which conflated "prove who you are" with +"decrypt my vault" and made it impossible to delegate authentication without +also losing the vault. Splitting them PRESERVES the capability — you still +cannot read a secret without the master key — while letting FuzeFront own +identity. + +`get_current_user` is re-exported from `app.security`, so every router doing +`from app.routers.auth import get_current_user` keeps working unchanged. +""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User +from app.security import ( # noqa: F401 (re-exported for existing routers) + Identity, + check_permission, + get_current_user, + get_security_client, + require_identity, + require_permission, +) +from app.utils.encryption import ( + generate_master_key_hash, + get_global_encryption_manager, + set_global_encryption_manager, + verify_master_key, +) +from app.utils.logging import get_logger, log_security_event + +logger = get_logger(__name__) +router = APIRouter() + +__all__ = [ + "router", + "get_current_user", + "require_identity", + "require_permission", + "check_permission", + "Identity", +] + + +# ── Request / response models ──────────────────────────────────────────────── +class UserResponse(BaseModel): + id: int + username: str + email: str + first_name: Optional[str] + last_name: Optional[str] + is_active: bool + is_verified: bool + created_at: Optional[datetime] + # Whether this user has completed vault setup. Lets the UI prompt for + # "set up your vault" instead of failing on the first encrypted read. + vault_initialized: bool + + +class VaultStatus(BaseModel): + vault_initialized: bool + vault_unlocked: bool + + +class MasterKeyRequest(BaseModel): + master_key: str = Field(..., min_length=8) + + +def _to_user_response(user: User) -> UserResponse: + return UserResponse( + id=user.id, + username=user.username, + email=user.email, + first_name=user.first_name, + last_name=user.last_name, + is_active=user.is_active, + is_verified=user.is_verified, + created_at=user.created_at, + vault_initialized=bool(user.master_key_hash), + ) + + +def _bearer(request: Request) -> Optional[str]: + header = request.headers.get("authorization") or "" + if header.lower().startswith("bearer "): + return header[7:].strip() or None + return None + + +# ── Session ────────────────────────────────────────────────────────────────── +@router.get("/me", response_model=UserResponse) +async def get_current_user_info(current_user: User = Depends(get_current_user)): + """The caller, as FuzeKeys sees them. + + The identity itself comes from `GET /v1/security/session`; this adds the + FuzeKeys-local projection (the integer id every vault relation points at, + plus vault status). + """ + return _to_user_response(current_user) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(request: Request): + """Revoke the session via FuzeFront (`DELETE /v1/security/session`). + + Previously a no-op that merely told the client to forget its token, leaving + a stolen token valid until expiry. Delegating to FuzeFront makes logout + actually revoke. + """ + token = _bearer(request) + if token: + await get_security_client().delete_session(token) + return None + + +# ── Vault (FuzeKeys domain) ────────────────────────────────────────────────── +@router.get("/vault", response_model=VaultStatus) +async def vault_status(current_user: User = Depends(get_current_user)): + """Whether this user has a vault master key, and whether it is unlocked.""" + return VaultStatus( + vault_initialized=bool(current_user.master_key_hash), + vault_unlocked=get_global_encryption_manager() is not None, + ) + + +@router.post("/vault/setup", response_model=VaultStatus) +async def vault_setup( + body: MasterKeyRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Set the vault master key for the first time. + + 409 if one already exists — re-keying an existing vault would orphan every + ciphertext already stored under the old key, so it is deliberately not a + silent overwrite. + """ + if current_user.master_key_hash: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A vault master key already exists for this account", + ) + + current_user.master_key_hash = generate_master_key_hash(body.master_key) + await db.commit() + + set_global_encryption_manager(body.master_key) + log_security_event("vault_master_key_set", user_id=current_user.id) + + return VaultStatus(vault_initialized=True, vault_unlocked=True) + + +@router.post("/vault/unlock", response_model=VaultStatus) +async def vault_unlock( + body: MasterKeyRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Unlock the vault with the master key. + + This is the capability that used to ride along on `POST /auth/login`. Its + effect is unchanged — a wrong master key still yields no plaintext — but it + is now an explicitly authenticated action rather than part of sign-in. + """ + if not current_user.master_key_hash: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="No vault master key has been set for this account", + ) + + if not verify_master_key(body.master_key, current_user.master_key_hash): + log_security_event( + "failed_vault_unlock", + user_id=current_user.id, + details={"reason": "invalid_master_key"}, + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect master key", + ) + + set_global_encryption_manager(body.master_key) + current_user.last_login = datetime.utcnow() + await db.commit() + + log_security_event("vault_unlocked", user_id=current_user.id) + return VaultStatus(vault_initialized=True, vault_unlocked=True) diff --git a/backend/app/routers/automation.py b/backend/app/routers/automation.py index d69d7cc..aeb79cf 100644 --- a/backend/app/routers/automation.py +++ b/backend/app/routers/automation.py @@ -6,12 +6,18 @@ from app.database import get_db from app.models.user import User from app.routers.auth import get_current_user +from app.security import require_permission from app.automation.web_scraper import analyze_website_signup from app.utils.logging import get_logger, log_automation_event logger = get_logger(__name__) router = APIRouter() +# Driving a real signup against a third-party site is `SignupScript:run` in +# `registration/policy.json` — deliberately separate from write permissions. +# The decision is FuzeFront's (`POST /v1/security/authz/check`), fail-closed. +_CAN_RUN_SCRIPT = Depends(require_permission("SignupScript", "run")) + class AnalyzeWebsiteRequest(BaseModel): url: str @@ -23,7 +29,7 @@ class AnalysisResponse(BaseModel): details: Optional[dict] = None -@router.post("/analyze", response_model=AnalysisResponse) +@router.post("/analyze", response_model=AnalysisResponse, dependencies=[_CAN_RUN_SCRIPT]) async def analyze_website( request: AnalyzeWebsiteRequest, current_user: User = Depends(get_current_user), diff --git a/backend/app/routers/identities.py b/backend/app/routers/identities.py index 366fec2..ccc19bf 100644 --- a/backend/app/routers/identities.py +++ b/backend/app/routers/identities.py @@ -9,12 +9,25 @@ from app.models.user import User from app.models.identity import Identity from app.routers.auth import get_current_user +from app.security import require_permission from app.utils.encryption import encrypt_field, decrypt_field, decrypt_json_field from app.utils.logging import get_logger logger = get_logger(__name__) router = APIRouter() +# Authorization is decided by FuzeFront (`POST /v1/security/authz/check`) using +# the BARE resource/action keys FuzeKeys declares in `registration/policy.json`. +# FuzeKeys evaluates no policy of its own; it asks and obeys, fail-closed. +# +# This runs IN ADDITION to the per-row ownership filter each handler already +# applies (`Identity.user_id == current_user.id`). Role check and ownership +# check answer different questions and neither replaces the other. +_CAN_READ = Depends(require_permission("Identity", "read")) +_CAN_CREATE = Depends(require_permission("Identity", "create")) +_CAN_UPDATE = Depends(require_permission("Identity", "update")) +_CAN_DELETE = Depends(require_permission("Identity", "delete")) + class IdentityCreate(BaseModel): name: str @@ -123,7 +136,7 @@ def decrypt_identity_data(identity: Identity) -> IdentityResponse: ) -@router.post("/", response_model=IdentityResponse) +@router.post("/", response_model=IdentityResponse, dependencies=[_CAN_CREATE]) async def create_identity( identity_data: IdentityCreate, current_user: User = Depends(get_current_user), @@ -170,7 +183,7 @@ async def create_identity( ) -@router.get("/", response_model=List[IdentityListResponse]) +@router.get("/", response_model=List[IdentityListResponse], dependencies=[_CAN_READ]) async def list_identities( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) @@ -200,7 +213,7 @@ async def list_identities( ) -@router.get("/{identity_id}", response_model=IdentityResponse) +@router.get("/{identity_id}", response_model=IdentityResponse, dependencies=[_CAN_READ]) async def get_identity( identity_id: int, current_user: User = Depends(get_current_user), @@ -233,7 +246,7 @@ async def get_identity( ) -@router.put("/{identity_id}", response_model=IdentityResponse) +@router.put("/{identity_id}", response_model=IdentityResponse, dependencies=[_CAN_UPDATE]) async def update_identity( identity_id: int, identity_data: IdentityUpdate, @@ -310,7 +323,7 @@ async def update_identity( ) -@router.delete("/{identity_id}") +@router.delete("/{identity_id}", dependencies=[_CAN_DELETE]) async def delete_identity( identity_id: int, current_user: User = Depends(get_current_user), diff --git a/backend/app/routers/site_integrations.py b/backend/app/routers/site_integrations.py index eff1de5..780dc45 100644 --- a/backend/app/routers/site_integrations.py +++ b/backend/app/routers/site_integrations.py @@ -3,6 +3,15 @@ This router provides API endpoints for managing automated site integrations including signup, signin, and API key creation for various platforms. + +NOTE ON `permit.io` IN THIS FILE — it is a TARGET SITE, not FuzeKeys' auth. +FuzeKeys is a credential-vault product whose job is driving browser automation +to create and manage accounts on third-party SaaS sites; `permit.io` is one such +site, exactly like `google.com` is in `app/integrations/google/`. These +references are FuzeKeys' DOMAIN (which sites it can automate) and are unrelated +to how FuzeKeys authenticates its own users — that is delegated wholly to the +FuzeFront Security API (see `app.security`). Removing them would delete a +product capability, not a coupling. """ from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends @@ -15,11 +24,19 @@ from app.integrations.site.permit_io.models import PermitIOCredentials, PermitIOResult from app.models.user import User from app.routers.auth import get_current_user +from app.security import require_permission logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/integrations", tags=["Site Integrations"]) +# Driving a real signup/signin against a third-party site is `SignupScript:run` +# in `registration/policy.json`; minting an API key there is `ApiKey:create`. +# Both decisions come from FuzeFront (`POST /v1/security/authz/check`), +# fail-closed, and sit on top of the existing `get_current_user` gate. +_CAN_RUN_SCRIPT = Depends(require_permission("SignupScript", "run")) +_CAN_CREATE_APIKEY = Depends(require_permission("ApiKey", "create")) + # Request/Response Models class SignupRequest(BaseModel): site: str @@ -80,7 +97,7 @@ async def get_site_capabilities_endpoint(site_name: str): raise HTTPException(status_code=404, detail=f"Site '{site_name}' not found") # Site Integration Operations -@router.post("/signup", response_model=IntegrationResponse) +@router.post("/signup", response_model=IntegrationResponse, dependencies=[_CAN_RUN_SCRIPT]) async def create_account( request: SignupRequest, background_tasks: BackgroundTasks, @@ -110,7 +127,7 @@ async def create_account( logger.error(f"Signup failed for {request.site}: {str(e)}") raise HTTPException(status_code=500, detail="Account creation failed") -@router.post("/signin", response_model=IntegrationResponse) +@router.post("/signin", response_model=IntegrationResponse, dependencies=[_CAN_RUN_SCRIPT]) async def authenticate_account( request: SigninRequest, current_user: User = Depends(get_current_user), @@ -139,7 +156,7 @@ async def authenticate_account( logger.error(f"Signin failed for {request.site}: {str(e)}") raise HTTPException(status_code=500, detail="Authentication failed") -@router.post("/apikey", response_model=IntegrationResponse) +@router.post("/apikey", response_model=IntegrationResponse, dependencies=[_CAN_CREATE_APIKEY]) async def create_api_key( request: ApiKeyRequest, current_user: User = Depends(get_current_user), diff --git a/backend/app/security/__init__.py b/backend/app/security/__init__.py new file mode 100644 index 0000000..3847551 --- /dev/null +++ b/backend/app/security/__init__.py @@ -0,0 +1,54 @@ +"""FuzeFront Security integration for FuzeKeys. + +FuzeKeys does NOT talk to any identity provider or authorization engine +directly. Every authentication and authorization decision is delegated to the +FuzeFront Security API (`@fuzefront/security-client` contract, `openapi.yaml` +in `packages/security` of the FuzeFront repo). + +The provider behind that API — federation, MFA, policy engine — is FuzeFront's +private implementation detail. Nothing in this package (or anywhere else in +FuzeKeys) may name a vendor. + +Public surface: + Identity normalized principal (contract keystone) + SecurityError provider-neutral failure + get_security_client() shared async HTTP client + require_identity FastAPI dep -> Identity + get_current_user FastAPI dep -> local User row (legacy name, + kept so every existing router is unchanged) + require_permission(res, act) FastAPI dep factory -> authz/check + check_permission(...) imperative authz/check + bulk_check_permissions(...) imperative authz/bulk-check +""" + +from .client import ( + FuzeFrontSecurityClient, + SecurityError, + close_security_client, + get_security_client, +) +from .contract import SECURITY_CONTRACT_MAJOR, AuthzCheck, Identity +from .dependencies import ( + bulk_check_permissions, + check_permission, + get_current_user, + optional_identity, + require_identity, + require_permission, +) + +__all__ = [ + "AuthzCheck", + "FuzeFrontSecurityClient", + "Identity", + "SECURITY_CONTRACT_MAJOR", + "SecurityError", + "bulk_check_permissions", + "check_permission", + "close_security_client", + "get_current_user", + "get_security_client", + "optional_identity", + "require_identity", + "require_permission", +] diff --git a/backend/app/security/client.py b/backend/app/security/client.py new file mode 100644 index 0000000..d4fa832 --- /dev/null +++ b/backend/app/security/client.py @@ -0,0 +1,328 @@ +"""Async HTTP client for the FuzeFront Security API. + +Only the endpoints FuzeKeys actually needs are implemented, each one taken +verbatim from the published contract (`packages/security/openapi.yaml`): + + GET /v1/security/session getSession -> SessionInfo + DELETE /v1/security/session deleteSession -> 204 + POST /v1/security/authz/check authzCheck -> AuthzDecision + POST /v1/security/authz/bulk-check authzBulkCheck -> AuthzBulkDecision + GET /v1/security/authz/permissions getPermissions -> PermissionSet + +No endpoint is invented here. If FuzeKeys needs a capability with no operation +in the contract, that is a contract gap to be raised against FuzeFront — never +worked around with a direct call to some other system. + +FAIL-CLOSED is the whole point of this module. Any transport error, timeout, +non-2xx status, or unparseable body results in a denial, never an allow. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional + +import httpx + +from app.utils.logging import get_logger + +from .contract import AuthzCheck, Identity + +logger = get_logger(__name__) + +# ── Configuration ───────────────────────────────────────────────────────────── +# This points at FUZEFRONT's own service. It is the ONLY auth-related endpoint +# FuzeKeys is allowed to know about, and it is deliberately platform-side +# config: the identity provider and policy engine sitting behind it are +# FuzeFront's business, invisible from here. +_DEFAULT_BASE_URL = "http://fuzefront-backend:3001" +_DEFAULT_TIMEOUT = 5.0 + + +def _base_url() -> str: + return os.getenv("FUZEFRONT_SECURITY_BASE_URL", _DEFAULT_BASE_URL).rstrip("/") + + +def _timeout() -> float: + try: + return float(os.getenv("FUZEFRONT_SECURITY_TIMEOUT_SECONDS", _DEFAULT_TIMEOUT)) + except (TypeError, ValueError): + return _DEFAULT_TIMEOUT + + +def _service_token() -> Optional[str]: + """Optional service-to-service token for server-initiated authz calls. + + Some deployments require the caller of `authz/check` to authenticate as a + service rather than replay the end-user token. When set, it is used for + authz calls made on behalf of a subject other than the caller. + """ + token = os.getenv("FUZEFRONT_SECURITY_SERVICE_TOKEN") + return token.strip() if token and token.strip() else None + + +class SecurityError(Exception): + """A provider-neutral security failure. + + `code` uses the contract's `SecurityErrorCode` vocabulary so callers never + have to interpret a vendor-specific error. + """ + + def __init__(self, code: str, message: str, status_code: int = 401): + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +class FuzeFrontSecurityClient: + """Thin, fail-closed client over the FuzeFront Security API.""" + + def __init__( + self, + base_url: Optional[str] = None, + timeout: Optional[float] = None, + transport: Optional[httpx.AsyncBaseTransport] = None, + ): + self.base_url = (base_url or _base_url()).rstrip("/") + self.timeout = timeout if timeout is not None else _timeout() + self._transport = transport + self._client: Optional[httpx.AsyncClient] = None + + # ── lifecycle ──────────────────────────────────────────────────────────── + def _http(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=self.timeout, + transport=self._transport, + ) + return self._client + + async def aclose(self) -> None: + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + self._client = None + + # ── session (AuthN) ────────────────────────────────────────────────────── + async def get_session(self, token: str) -> Identity: + """`GET /v1/security/session` — verify a token and return the identity. + + This REPLACES local token verification entirely. FuzeKeys does not hold + a signing key, does not decode a JWT, and does not know the token + format; it asks FuzeFront who the caller is. + """ + if not token or not token.strip(): + raise SecurityError("NO_TOKEN", "No session token presented", 401) + + try: + response = await self._http().get( + "/v1/security/session", + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError as exc: + # Fail closed: an unreachable verifier is NOT an allow. + logger.error("Security session verification unavailable: %s", exc) + raise SecurityError( + "VERIFIER_UNAVAILABLE", + "Authentication service unavailable", + 503, + ) from exc + + if response.status_code == 401: + raise SecurityError("INVALID_SIGNATURE", "Could not validate credentials", 401) + if response.status_code >= 500: + raise SecurityError( + "VERIFIER_UNAVAILABLE", "Authentication service unavailable", 503 + ) + if response.status_code != 200: + raise SecurityError("UNKNOWN", "Could not validate credentials", 401) + + try: + return Identity.from_session_info(response.json()) + except (ValueError, TypeError) as exc: + logger.error("Malformed SessionInfo from security service: %s", exc) + raise SecurityError("MALFORMED", "Could not validate credentials", 401) from exc + + async def delete_session(self, token: str) -> None: + """`DELETE /v1/security/session` — revoke the presented session. + + Idempotent by contract. A transport failure is logged, not raised: the + client discards its token regardless, and refusing to "log out" because + the revoke call flapped is worse UX with no security gain (the token + still expires). + """ + if not token or not token.strip(): + return + try: + await self._http().delete( + "/v1/security/session", + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError as exc: + logger.warning("Session revoke call failed (token discarded anyway): %s", exc) + + # ── authz ──────────────────────────────────────────────────────────────── + def _authz_headers(self, caller_token: Optional[str]) -> Dict[str, str]: + token = _service_token() or caller_token + return {"Authorization": f"Bearer {token}"} if token else {} + + async def authz_check( + self, + subject: str, + tenant: str, + check: AuthzCheck, + caller_token: Optional[str] = None, + ) -> bool: + """`POST /v1/security/authz/check` — one decision. Fail-closed.""" + try: + response = await self._http().post( + "/v1/security/authz/check", + json=check.to_payload(subject, tenant), + headers=self._authz_headers(caller_token), + ) + except httpx.HTTPError as exc: + logger.error("authz/check unavailable, denying: %s", exc) + return False + + if response.status_code != 200: + logger.warning( + "authz/check returned %s for %s:%s, denying", + response.status_code, + check.resource_type, + check.action, + ) + return False + + try: + body = response.json() + except ValueError: + logger.error("authz/check returned an unparseable body, denying") + return False + + return bool(isinstance(body, dict) and body.get("allow") is True) + + async def authz_bulk_check( + self, + subject: str, + tenant: str, + checks: List[AuthzCheck], + caller_token: Optional[str] = None, + ) -> List[bool]: + """`POST /v1/security/authz/bulk-check` — index-aligned decisions. + + Fail-closed per element AND in aggregate: a transport failure, a + non-200, or a length mismatch yields all-False rather than a partial + list that a caller might mis-index. + """ + if not checks: + return [] + # Contract bounds the array at maxItems: 200. + if len(checks) > 200: + raise ValueError("authz/bulk-check accepts at most 200 checks per call") + + payload = {"checks": [c.to_payload(subject, tenant) for c in checks]} + try: + response = await self._http().post( + "/v1/security/authz/bulk-check", + json=payload, + headers=self._authz_headers(caller_token), + ) + except httpx.HTTPError as exc: + logger.error("authz/bulk-check unavailable, denying all: %s", exc) + return [False] * len(checks) + + if response.status_code != 200: + logger.warning( + "authz/bulk-check returned %s, denying all", response.status_code + ) + return [False] * len(checks) + + try: + body = response.json() + decisions = body["decisions"] + except (ValueError, KeyError, TypeError): + logger.error("authz/bulk-check returned an unparseable body, denying all") + return [False] * len(checks) + + if not isinstance(decisions, list) or len(decisions) != len(checks): + logger.error( + "authz/bulk-check returned %s decisions for %s checks, denying all", + len(decisions) if isinstance(decisions, list) else "?", + len(checks), + ) + return [False] * len(checks) + + return [bool(isinstance(d, dict) and d.get("allow") is True) for d in decisions] + + async def get_permissions( + self, + subject: str, + tenant: str, + caller_token: Optional[str] = None, + ) -> List[str]: + """`GET /v1/security/authz/permissions` — effective `resource:action` set. + + Advisory only (used to render UI affordances). `authz/check` stays + authoritative for every actual decision. Fail-closed to an empty set. + """ + try: + response = await self._http().get( + "/v1/security/authz/permissions", + params={"subject": subject, "tenant": tenant}, + headers=self._authz_headers(caller_token), + ) + except httpx.HTTPError as exc: + logger.error("authz/permissions unavailable: %s", exc) + return [] + + if response.status_code != 200: + return [] + + try: + body = response.json() + permissions = body["permissions"] + except (ValueError, KeyError, TypeError): + return [] + + return [str(p) for p in permissions] if isinstance(permissions, list) else [] + + +# ── Process-wide shared instance ───────────────────────────────────────────── +_client: Optional[FuzeFrontSecurityClient] = None + + +def get_security_client() -> FuzeFrontSecurityClient: + """Return the shared client (connection-pooled).""" + global _client + if _client is None: + _client = FuzeFrontSecurityClient() + return _client + + +def set_security_client(client: Optional[FuzeFrontSecurityClient]) -> None: + """Swap the shared client. For tests and for app startup wiring.""" + global _client + _client = client + + +async def close_security_client() -> None: + global _client + if _client is not None: + await _client.aclose() + _client = None + + +def _reset_config_cache_for_tests() -> None: # pragma: no cover - test helper + set_security_client(None) + + +def get_authz_tenant_fallback() -> Optional[str]: + """Tenant to use when the identity carries no tenant. + + Deliberately unset by default: with no tenant, tenant-scoped decisions fail + closed exactly as the contract requires. A single-tenant deployment may set + `FUZEFRONT_DEFAULT_TENANT` to opt in explicitly. + """ + tenant = os.getenv("FUZEFRONT_DEFAULT_TENANT") + return tenant.strip() if tenant and tenant.strip() else None diff --git a/backend/app/security/contract.py b/backend/app/security/contract.py new file mode 100644 index 0000000..8be5ecb --- /dev/null +++ b/backend/app/security/contract.py @@ -0,0 +1,117 @@ +"""Provider-neutral shapes mirrored from the FuzeFront Security contract. + +These are the Python counterparts of the TypeScript types exported by +`@fuzefront/security-client` (`packages/security/src/types.ts` + +`openapi.yaml`). They are deliberately a hand-mirror rather than a generated +artifact: FuzeKeys consumes only a small slice of the contract (session + +authz), and a generated Python client is not published for it. + +If any shape here drifts from the published contract, the mismatch surfaces as +a parse failure in `client.py`, not as a silent allow. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +# Major of the FuzeFront Security contract this module is written against. +# The contract states consumers may assert on the major (`SECURITY_CONTRACT_VERSION`). +SECURITY_CONTRACT_MAJOR = 0 + +# Verification modes the contract may report. Provider-neutral by design: +# these name token formats, never a vendor. +AUTH_MODES = ("legacy-hs256", "federated-jwks") + + +@dataclass(frozen=True) +class Identity: + """The stable normalized identity — the contract's keystone. + + Mirrors `components.schemas.Identity`. `tenant_id` is `None` when the token + carries no resolvable tenant; consumers MUST fail closed on any + tenant-scoped authorization decision when that happens. + """ + + user_id: str + tenant_id: Optional[str] + roles: List[str] + auth_mode: str + email: Optional[str] = None + issued_at: Optional[int] = None + expires_at: Optional[int] = None + issuer: Optional[str] = None + # Hydrated user projection returned alongside the identity by + # `GET /v1/security/session` (`components.schemas.SessionInfo.user`). + user: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_session_info(cls, payload: Dict[str, Any]) -> "Identity": + """Parse a `SessionInfo` body. Raises ValueError on a malformed body.""" + if not isinstance(payload, dict): + raise ValueError("SessionInfo body is not an object") + + identity = payload.get("identity") + if not isinstance(identity, dict): + raise ValueError("SessionInfo.identity missing or not an object") + + user_id = identity.get("userId") + if not isinstance(user_id, str) or not user_id: + raise ValueError("Identity.userId missing") + + roles = identity.get("roles") + if not isinstance(roles, list): + raise ValueError("Identity.roles missing or not an array") + + auth_mode = identity.get("authMode") + if not isinstance(auth_mode, str): + raise ValueError("Identity.authMode missing") + + tenant_id = identity.get("tenantId") + if tenant_id is not None and not isinstance(tenant_id, str): + raise ValueError("Identity.tenantId must be a string or null") + + user = payload.get("user") + if not isinstance(user, dict): + user = {} + + return cls( + user_id=user_id, + tenant_id=tenant_id, + roles=[str(r) for r in roles], + auth_mode=auth_mode, + email=identity.get("email") or user.get("email"), + issued_at=identity.get("issuedAt"), + expires_at=identity.get("expiresAt"), + issuer=identity.get("issuer"), + user=user, + ) + + +@dataclass(frozen=True) +class AuthzCheck: + """One `AuthzCheckRequest`. + + `resource_type` / `action` are the BARE keys FuzeKeys already declares in + `registration/policy.json` (e.g. `VaultAsset` / `reveal`). FuzeKeys never + encodes an engine-specific policy identifier. + """ + + resource_type: str + action: str + resource_key: Optional[str] = None + context: Optional[Dict[str, Any]] = None + + def to_payload(self, subject: str, tenant: str) -> Dict[str, Any]: + resource: Dict[str, Any] = {"type": self.resource_type} + if self.resource_key is not None: + resource["key"] = self.resource_key + payload: Dict[str, Any] = { + "subject": subject, + "tenant": tenant, + "resource": resource, + "action": self.action, + } + if self.context: + payload["context"] = self.context + return payload diff --git a/backend/app/security/dependencies.py b/backend/app/security/dependencies.py new file mode 100644 index 0000000..11da2d4 --- /dev/null +++ b/backend/app/security/dependencies.py @@ -0,0 +1,291 @@ +"""FastAPI dependencies backed by the FuzeFront Security API. + +`get_current_user` keeps its historical name and return type (the local `User` +row) on purpose: ~15 routers and every ownership check depend on +`current_user.id` being the local integer primary key that all foreign keys +point at. Swapping the *implementation* under a stable seam migrates the whole +backend without touching a single feature router — and without a data +migration of every FK. + +What changed underneath: + before — decode a locally-minted HS256 JWT with a local SECRET_KEY + after — ask FuzeFront `GET /v1/security/session` who the caller is, then + resolve (or provision) the local row that mirrors that subject +""" + +from __future__ import annotations + +import re +from typing import Callable, List, Optional + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.database import get_db +from app.models.user import User +from app.utils.logging import get_logger + +from .client import ( + FuzeFrontSecurityClient, + SecurityError, + get_authz_tenant_fallback, + get_security_client, +) +from .contract import AuthzCheck, Identity + +logger = get_logger(__name__) + +# auto_error=False so a missing header produces our own provider-neutral 401 +# with a WWW-Authenticate challenge, identical to the pre-migration behaviour. +_bearer = HTTPBearer(auto_error=False) + +_CREDENTIALS_EXCEPTION = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, +) + + +def _bearer_token(credentials: Optional[HTTPAuthorizationCredentials]) -> str: + if credentials is None or not credentials.credentials: + raise _CREDENTIALS_EXCEPTION + return credentials.credentials + + +async def require_identity( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> Identity: + """Resolve the caller's normalized `Identity` via FuzeFront. Fail-closed.""" + token = _bearer_token(credentials) + client = get_security_client() + try: + return await client.get_session(token) + except SecurityError as exc: + if exc.status_code == 503: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Authentication service unavailable", + ) from exc + raise _CREDENTIALS_EXCEPTION from exc + + +async def optional_identity( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> Optional[Identity]: + """Like `require_identity` but returns None instead of raising on no/bad token.""" + if credentials is None or not credentials.credentials: + return None + try: + return await get_security_client().get_session(credentials.credentials) + except SecurityError: + return None + + +_USERNAME_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") + + +def _derive_username(identity: Identity) -> str: + """Best-effort local display handle for a FuzeFront subject. + + Purely cosmetic — `fuzefront_user_id` is the identity key. The local + `username` column predates the migration, is UNIQUE NOT NULL, and is shown + in the UI, so it still needs a value. + """ + candidate = "" + if identity.email: + candidate = identity.email.split("@", 1)[0] + if not candidate: + candidate = identity.user.get("firstName") or "" + candidate = _USERNAME_SAFE.sub("", candidate)[:80] + return candidate or f"user-{identity.user_id[:16]}" + + +async def resolve_local_user(identity: Identity, db: AsyncSession) -> User: + """Map a FuzeFront subject onto the local `users` row. + + The local row is a PROJECTION, not a credential store: it exists so that + `identities.user_id`, `accounts.user_id` etc. keep working. It holds no + password (FuzeFront owns authentication) — only the FuzeFront subject id, + the profile fields FuzeKeys renders, and the vault master-key hash, which + is FuzeKeys' own domain secret and has nothing to do with sign-in. + """ + result = await db.execute( + select(User).where(User.fuzefront_user_id == identity.user_id) + ) + user = result.scalar_one_or_none() + + if user is None and identity.email: + # Adopt a pre-migration row that was created by the old local signup, + # so existing vaults and their master-key hash survive the cutover. + result = await db.execute(select(User).where(User.email == identity.email)) + legacy = result.scalar_one_or_none() + if legacy is not None and legacy.fuzefront_user_id is None: + legacy.fuzefront_user_id = identity.user_id + user = legacy + logger.info( + "Linked pre-existing FuzeKeys user %s to FuzeFront subject", legacy.id + ) + + if user is None: + base_username = _derive_username(identity) + username = base_username + suffix = 0 + while True: + clash = await db.execute(select(User).where(User.username == username)) + if clash.scalar_one_or_none() is None: + break + suffix += 1 + username = f"{base_username}-{suffix}" + + user = User( + fuzefront_user_id=identity.user_id, + email=identity.email or f"{identity.user_id}@users.noreply.fuzefront", + username=username, + first_name=identity.user.get("firstName"), + last_name=identity.user.get("lastName"), + is_active=True, + # Verified upstream — FuzeFront would not have issued a session for + # an account it considers unusable. + is_verified=True, + ) + db.add(user) + logger.info("Provisioned local FuzeKeys user for a FuzeFront subject") + + # Keep the projection fresh (email/name can change upstream). + if identity.email and user.email != identity.email: + user.email = identity.email + first_name = identity.user.get("firstName") + last_name = identity.user.get("lastName") + if first_name and user.first_name != first_name: + user.first_name = first_name + if last_name and user.last_name != last_name: + user.last_name = last_name + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Account is deactivated", + ) + + await db.commit() + await db.refresh(user) + return user + + +async def get_current_user( + identity: Identity = Depends(require_identity), + db: AsyncSession = Depends(get_db), +) -> User: + """The seam every feature router already depends on. + + Same name, same return type, same object-level guarantees as before — but + the identity now comes from FuzeFront instead of a locally-signed token. + """ + return await resolve_local_user(identity, db) + + +# ── Authorization ───────────────────────────────────────────────────────────── +def _tenant_for(identity: Identity) -> str: + tenant = identity.tenant_id or get_authz_tenant_fallback() + if not tenant: + # Contract: "Consumers fail-closed on tenant-scoped decisions when this + # is null." Denying is the whole point — do not substitute a guess. + logger.warning( + "Authorization denied: identity carries no tenant and no explicit " + "FUZEFRONT_DEFAULT_TENANT is configured" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not permitted", + ) + return tenant + + +def _caller_token(request: Optional[Request]) -> Optional[str]: + if request is None: + return None + header = request.headers.get("authorization") or "" + if header.lower().startswith("bearer "): + return header[7:].strip() or None + return None + + +async def check_permission( + identity: Identity, + resource_type: str, + action: str, + resource_key: Optional[str] = None, + caller_token: Optional[str] = None, + client: Optional[FuzeFrontSecurityClient] = None, +) -> bool: + """Imperative single authorization check. Fail-closed. + + `resource_type` / `action` are the bare keys from + `registration/policy.json` — e.g. ("VaultAsset", "reveal"). + """ + tenant = identity.tenant_id or get_authz_tenant_fallback() + if not tenant: + return False + return await (client or get_security_client()).authz_check( + subject=identity.user_id, + tenant=tenant, + check=AuthzCheck( + resource_type=resource_type, action=action, resource_key=resource_key + ), + caller_token=caller_token, + ) + + +async def bulk_check_permissions( + identity: Identity, + checks: List[AuthzCheck], + caller_token: Optional[str] = None, + client: Optional[FuzeFrontSecurityClient] = None, +) -> List[bool]: + """Imperative bulk authorization check, index-aligned. Fail-closed.""" + tenant = identity.tenant_id or get_authz_tenant_fallback() + if not tenant: + return [False] * len(checks) + return await (client or get_security_client()).authz_bulk_check( + subject=identity.user_id, + tenant=tenant, + checks=checks, + caller_token=caller_token, + ) + + +def require_permission( + resource_type: str, + action: str, +) -> Callable: + """FastAPI dependency factory enforcing one `resource:action`. + + Usage: + @router.post("/reveal", dependencies=[Depends(require_permission("VaultAsset", "reveal"))]) + + Denial is a 403 raised BEFORE the handler runs, and any failure to obtain a + decision is also a denial. + """ + + async def _dependency( + request: Request, + identity: Identity = Depends(require_identity), + ) -> Identity: + tenant = _tenant_for(identity) + allowed = await get_security_client().authz_check( + subject=identity.user_id, + tenant=tenant, + check=AuthzCheck(resource_type=resource_type, action=action), + caller_token=_caller_token(request), + ) + if not allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not permitted", + ) + return identity + + _dependency.__name__ = f"require_{resource_type}_{action}" + return _dependency diff --git a/backend/app/utils/encryption.py b/backend/app/utils/encryption.py index 213f408..d0120ed 100644 --- a/backend/app/utils/encryption.py +++ b/backend/app/utils/encryption.py @@ -102,30 +102,41 @@ def decrypt_json(self, encrypted_data: str) -> Optional[Dict]: return None -def hash_password(password: str) -> str: - """Hash a password using bcrypt.""" - return pwd_context.hash(password) +def _hash_secret(secret: str) -> str: + """Hash a FuzeKeys-domain secret (the vault master key) using bcrypt. + PRIVATE on purpose. There used to be public `hash_password` / + `verify_password` helpers here and they were used to authenticate users + locally. User authentication now belongs to the FuzeFront Security API — + FuzeKeys stores no user password at all — so exposing a general-purpose + password hasher would only invite that mistake back in. The one remaining + caller is the vault master-key verifier below, which is domain state, not a + login credential. + """ + return pwd_context.hash(secret) -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a password against its hash.""" - return pwd_context.verify(plain_password, hashed_password) + +def _verify_secret(plain_secret: str, stored_hash: str) -> bool: + """Verify a FuzeKeys-domain secret against its stored hash. PRIVATE.""" + return pwd_context.verify(plain_secret, stored_hash) def generate_master_key_hash(master_key: str, salt: Optional[str] = None) -> str: - """Generate a hash of the master key for storage.""" + """Generate a hash of the vault master key for storage.""" if salt is None: salt = os.getenv("MASTER_KEY_SALT", "default_salt") - - return hash_password(master_key + salt) + + return _hash_secret(master_key + salt) def verify_master_key(master_key: str, stored_hash: str, salt: Optional[str] = None) -> bool: - """Verify a master key against its stored hash.""" + """Verify a vault master key against its stored hash. Fail-closed.""" if salt is None: salt = os.getenv("MASTER_KEY_SALT", "default_salt") - - return verify_password(master_key + salt, stored_hash) + if not stored_hash: + return False + + return _verify_secret(master_key + salt, stored_hash) def create_encryption_manager(master_key: str) -> EncryptionManager: diff --git a/backend/env.example b/backend/env.example index 232f5f9..63260a5 100644 --- a/backend/env.example +++ b/backend/env.example @@ -5,12 +5,37 @@ DATABASE_URL=postgresql://fuzekeys_user:fuzekeys_dev_password@localhost:5433/fuzekeys DATABASE_URL_ASYNC=postgresql+asyncpg://fuzekeys_user:fuzekeys_dev_password@localhost:5433/fuzekeys -# Security -SECRET_KEY=your-super-secret-key-here-change-this-in-production -ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRE_MINUTES=30 +# Authentication & Authorization — FuzeFront Security API +# +# FuzeKeys does not authenticate users and holds no session signing key. Every +# AuthN/AuthZ decision is delegated to the FuzeFront Security API. Which +# identity provider and policy engine sit behind it is FuzeFront's business and +# is deliberately not configurable — or even nameable — from here. +# +# Base URL of FuzeFront's backend (this is FuzeFront's OWN service, not a +# third-party identity provider). +FUZEFRONT_SECURITY_BASE_URL=http://fuzefront-backend:3001 +FUZEFRONT_SECURITY_TIMEOUT_SECONDS=5 + +# Optional service-to-service token for server-initiated authz/check calls. +# Leave blank to replay the end-user's bearer token instead. +FUZEFRONT_SECURITY_SERVICE_TOKEN= + +# Optional. Tenant to use when a session carries no tenantId. UNSET BY DEFAULT +# and it should stay that way: per the security contract, consumers fail closed +# on tenant-scoped decisions when tenantId is null. Only set this in a +# genuinely single-tenant deployment, as a deliberate opt-in. +FUZEFRONT_DEFAULT_TENANT= + +# Secret-broker signing key (RFC 8693 token exchange for A2A delegation). +# This is a SERVICE-TO-SERVICE key and is explicitly NOT user authentication — +# it never signs a user session. SECRET_KEY remains only as its legacy fallback. +BROKER_SIGNING_KEY=your-broker-signing-key-here +SECRET_KEY= # Encryption +# MASTER_KEY_SALT salts the VAULT master key — FuzeKeys' own domain secret that +# decrypts the vault. It is not a login credential and FuzeFront never sees it. ENCRYPTION_KEY=your-encryption-key-here-32-bytes-long MASTER_KEY_SALT=your-master-key-salt-here diff --git a/backend/seed_demo_user.py b/backend/seed_demo_user.py index f72d58d..cb24475 100644 --- a/backend/seed_demo_user.py +++ b/backend/seed_demo_user.py @@ -1,7 +1,12 @@ #!/usr/bin/env python3 """ Standalone seed script: creates the fuzekeys DB tables (via SQLAlchemy create_all) -and inserts the demo user (demo_user / demo123, sha256-hashed password). +and inserts the demo user. + +The demo user has NO password. FuzeKeys does not authenticate users — the +FuzeFront Security API does — so the seeded row is a local projection keyed by +a placeholder FuzeFront subject id, carrying only the vault master key, which +is FuzeKeys domain state rather than a login credential. Run from backend/ with: DATABASE_URL=postgresql://fuzekeys_user:fuzekeys_dev_password@localhost:5433/fuzekeys \ @@ -52,7 +57,7 @@ class Base(DeclarativeBase): from app.models.account import Account, AccountStage, StageType, StageStatus from app.models.signup_script import SignupScript from app.models.api_key import ApiKey -from app.utils.encryption import EncryptionManager +from app.utils.encryption import EncryptionManager, generate_master_key_hash async def seed(): @@ -65,20 +70,21 @@ async def seed(): existing = await session.get(User, 1) if existing: print(f"Demo user already exists: id={existing.id} username={existing.username}") - # Verify password hash matches - expected_hash = hashlib.sha256("demo123".encode()).hexdigest() - if existing.hashed_password == expected_hash: - print("Password hash matches demo123 — login will work.") + if existing.fuzefront_user_id: + print(f"Linked to FuzeFront subject: {existing.fuzefront_user_id}") else: - print("WARNING: password hash does not match demo123!") + print( + "WARNING: no fuzefront_user_id — it will be adopted by email " + "on the first FuzeFront-authenticated request." + ) return print("Seeding demo user...") demo_user = User( + fuzefront_user_id="demo-fuzefront-subject", email="demo@fuzekeys.local", username="demo_user", - hashed_password=hashlib.sha256("demo123".encode()).hexdigest(), - master_key_hash=hashlib.sha256("masterkey123".encode()).hexdigest(), + master_key_hash=generate_master_key_hash("masterkey123"), is_active=True, ) session.add(demo_user) diff --git a/backend/tests/test_fuzefront_security.py b/backend/tests/test_fuzefront_security.py new file mode 100644 index 0000000..9a10b51 --- /dev/null +++ b/backend/tests/test_fuzefront_security.py @@ -0,0 +1,382 @@ +"""Tests for the FuzeFront Security integration. + +These cover the two properties that matter most about delegating auth: + + 1. FuzeKeys asks FuzeFront who the caller is and never decides for itself — + no local signing key, no local password, no local policy evaluation. + 2. Every failure mode is a DENIAL. An unreachable security service, a + malformed response, a missing tenant, a length-mismatched bulk decision: + all fail closed. An authorization layer that opens when it breaks is + worse than none, because it is trusted. + +The security service is stubbed with an httpx MockTransport rather than a +network call, so the assertions are about OUR behaviour at each contract +boundary, not about a live FuzeFront. +""" + +import json + +import httpx +import pytest + +from app.security.client import ( + FuzeFrontSecurityClient, + SecurityError, +) +from app.security.contract import AuthzCheck, Identity + + +# ── helpers ─────────────────────────────────────────────────────────────────── +SESSION_BODY = { + "identity": { + "userId": "ff-subject-123", + "tenantId": "tenant-abc", + "roles": ["operator"], + "authMode": "federated-jwks", + "email": "user@example.test", + }, + "user": { + "id": "ff-subject-123", + "email": "user@example.test", + "firstName": "Ada", + "lastName": "Lovelace", + "roles": ["operator"], + }, +} + + +def _client(handler) -> FuzeFrontSecurityClient: + return FuzeFrontSecurityClient( + base_url="http://security.test", + transport=httpx.MockTransport(handler), + ) + + +# ── contract parsing ───────────────────────────────────────────────────────── +def test_identity_parses_session_info(): + identity = Identity.from_session_info(SESSION_BODY) + assert identity.user_id == "ff-subject-123" + assert identity.tenant_id == "tenant-abc" + assert identity.roles == ["operator"] + assert identity.email == "user@example.test" + assert identity.auth_mode == "federated-jwks" + assert identity.user["firstName"] == "Ada" + + +def test_identity_accepts_null_tenant(): + """`tenantId: null` is valid per the contract (legacy token mode).""" + body = json.loads(json.dumps(SESSION_BODY)) + body["identity"]["tenantId"] = None + assert Identity.from_session_info(body).tenant_id is None + + +@pytest.mark.parametrize( + "mutate", + [ + lambda b: b.pop("identity"), + lambda b: b["identity"].pop("userId"), + lambda b: b["identity"].pop("roles"), + lambda b: b["identity"].pop("authMode"), + lambda b: b["identity"].update(tenantId=123), + ], +) +def test_identity_rejects_malformed_session_info(mutate): + body = json.loads(json.dumps(SESSION_BODY)) + mutate(body) + with pytest.raises(ValueError): + Identity.from_session_info(body) + + +def test_authz_check_payload_uses_bare_policy_keys(): + """The wire payload carries the bare keys from registration/policy.json.""" + payload = AuthzCheck("VaultAsset", "reveal", resource_key="cred-9").to_payload( + subject="ff-subject-123", tenant="tenant-abc" + ) + assert payload == { + "subject": "ff-subject-123", + "tenant": "tenant-abc", + "resource": {"type": "VaultAsset", "key": "cred-9"}, + "action": "reveal", + } + + +# ── session verification ───────────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_get_session_calls_the_contract_endpoint_with_bearer(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["auth"] = request.headers.get("authorization") + return httpx.Response(200, json=SESSION_BODY) + + identity = await _client(handler).get_session("tok-abc") + assert seen["url"] == "http://security.test/v1/security/session" + assert seen["auth"] == "Bearer tok-abc" + assert identity.user_id == "ff-subject-123" + + +@pytest.mark.asyncio +async def test_get_session_rejects_empty_token(): + def handler(request): # pragma: no cover - must never be called + raise AssertionError("no request should be made without a token") + + with pytest.raises(SecurityError) as exc: + await _client(handler).get_session("") + assert exc.value.code == "NO_TOKEN" + + +@pytest.mark.asyncio +async def test_get_session_401_is_a_denial(): + handler = lambda r: httpx.Response(401, json={"code": "EXPIRED"}) + with pytest.raises(SecurityError) as exc: + await _client(handler).get_session("tok") + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_session_unreachable_service_fails_closed(): + """An unreachable verifier must never be treated as "probably fine".""" + + def handler(request): + raise httpx.ConnectError("boom") + + with pytest.raises(SecurityError) as exc: + await _client(handler).get_session("tok") + assert exc.value.code == "VERIFIER_UNAVAILABLE" + assert exc.value.status_code == 503 + + +@pytest.mark.asyncio +async def test_get_session_malformed_body_fails_closed(): + handler = lambda r: httpx.Response(200, json={"nope": True}) + with pytest.raises(SecurityError) as exc: + await _client(handler).get_session("tok") + assert exc.value.code == "MALFORMED" + + +@pytest.mark.asyncio +async def test_delete_session_hits_the_contract_endpoint(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + return httpx.Response(204) + + await _client(handler).delete_session("tok") + assert seen["method"] == "DELETE" + assert seen["url"] == "http://security.test/v1/security/session" + + +@pytest.mark.asyncio +async def test_delete_session_swallows_transport_errors(): + """Logout must not 500 because the revoke call flapped.""" + + def handler(request): + raise httpx.ConnectError("boom") + + await _client(handler).delete_session("tok") # must not raise + + +# ── authorization ──────────────────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_authz_check_allows_on_explicit_allow(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["body"] = json.loads(request.content) + return httpx.Response(200, json={"allow": True}) + + allowed = await _client(handler).authz_check( + subject="s", tenant="t", check=AuthzCheck("VaultAsset", "reveal") + ) + assert allowed is True + assert seen["url"] == "http://security.test/v1/security/authz/check" + assert seen["body"]["resource"] == {"type": "VaultAsset"} + assert seen["body"]["action"] == "reveal" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response_factory", + [ + lambda r: httpx.Response(200, json={"allow": False}), + lambda r: httpx.Response(200, json={}), # no decision at all + lambda r: httpx.Response(200, text="not json"), + lambda r: httpx.Response(500), + lambda r: httpx.Response(403), + ], + ids=["deny", "no-key", "unparseable", "server-error", "forbidden"], +) +async def test_authz_check_denies_on_anything_but_an_explicit_allow(response_factory): + allowed = await _client(response_factory).authz_check( + subject="s", tenant="t", check=AuthzCheck("VaultAsset", "reveal") + ) + assert allowed is False + + +@pytest.mark.asyncio +async def test_authz_check_denies_when_service_is_unreachable(): + def handler(request): + raise httpx.ConnectTimeout("boom") + + allowed = await _client(handler).authz_check( + subject="s", tenant="t", check=AuthzCheck("VaultAsset", "reveal") + ) + assert allowed is False + + +@pytest.mark.asyncio +async def test_bulk_check_is_index_aligned(): + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert len(body["checks"]) == 3 + return httpx.Response( + 200, + json={"decisions": [{"allow": True}, {"allow": False}, {"allow": True}]}, + ) + + checks = [ + AuthzCheck("Identity", "read"), + AuthzCheck("VaultAsset", "reveal"), + AuthzCheck("Account", "create"), + ] + assert await _client(handler).authz_bulk_check("s", "t", checks) == [ + True, + False, + True, + ] + + +@pytest.mark.asyncio +async def test_bulk_check_length_mismatch_denies_everything(): + """A short decision list must not be silently zipped against the requests. + + Index-aligning a 2-element response onto 3 checks would grant check[0]'s + decision to the wrong resource. Deny all instead. + """ + handler = lambda r: httpx.Response( + 200, json={"decisions": [{"allow": True}, {"allow": True}]} + ) + checks = [ + AuthzCheck("Identity", "read"), + AuthzCheck("VaultAsset", "reveal"), + AuthzCheck("Account", "create"), + ] + assert await _client(handler).authz_bulk_check("s", "t", checks) == [ + False, + False, + False, + ] + + +@pytest.mark.asyncio +async def test_bulk_check_unreachable_denies_everything(): + def handler(request): + raise httpx.ConnectError("boom") + + checks = [AuthzCheck("Identity", "read"), AuthzCheck("Account", "read")] + assert await _client(handler).authz_bulk_check("s", "t", checks) == [False, False] + + +@pytest.mark.asyncio +async def test_bulk_check_rejects_more_than_the_contract_maximum(): + """`AuthzBulkCheckRequest.checks` has maxItems: 200.""" + handler = lambda r: httpx.Response(200, json={"decisions": []}) + with pytest.raises(ValueError): + await _client(handler).authz_bulk_check( + "s", "t", [AuthzCheck("Identity", "read")] * 201 + ) + + +@pytest.mark.asyncio +async def test_permissions_fails_closed_to_an_empty_set(): + def handler(request): + raise httpx.ConnectError("boom") + + assert await _client(handler).get_permissions("s", "t") == [] + + +# ── tenant fail-closed ─────────────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_check_permission_denies_when_identity_has_no_tenant(monkeypatch): + """Contract: consumers fail closed on tenant-scoped authz when tenantId is null.""" + from app.security import dependencies as deps + + monkeypatch.delenv("FUZEFRONT_DEFAULT_TENANT", raising=False) + + def handler(request): # pragma: no cover - must never be reached + raise AssertionError("must not call authz/check without a tenant") + + identity = Identity( + user_id="s", tenant_id=None, roles=[], auth_mode="federated-jwks" + ) + assert ( + await deps.check_permission( + identity, "VaultAsset", "reveal", client=_client(handler) + ) + is False + ) + + +@pytest.mark.asyncio +async def test_bulk_check_permissions_denies_all_without_a_tenant(monkeypatch): + from app.security import dependencies as deps + + monkeypatch.delenv("FUZEFRONT_DEFAULT_TENANT", raising=False) + + def handler(request): # pragma: no cover + raise AssertionError("must not call authz/bulk-check without a tenant") + + identity = Identity( + user_id="s", tenant_id=None, roles=[], auth_mode="federated-jwks" + ) + decisions = await deps.bulk_check_permissions( + identity, + [AuthzCheck("Identity", "read"), AuthzCheck("Account", "read")], + client=_client(handler), + ) + assert decisions == [False, False] + + +# ── the coupling this migration removes ────────────────────────────────────── +def test_no_local_password_helpers_are_exported(): + """`hash_password` / `verify_password` are gone from the public surface. + + They existed only to authenticate users locally. Authentication is + FuzeFront's now; a public password hasher here is a loaded gun. + """ + import app.utils.encryption as enc + + assert not hasattr(enc, "hash_password") + assert not hasattr(enc, "verify_password") + + +def test_user_model_stores_no_password(): + from app.models.user import User + + columns = {c.name for c in User.__table__.columns} + assert "hashed_password" not in columns + assert "fuzefront_user_id" in columns + # The vault master key is DOMAIN state and stays. + assert "master_key_hash" in columns + + +def test_auth_router_exposes_no_login_or_register(): + """There is nothing to log in to here — FuzeFront owns sign-in.""" + from app.routers import auth as auth_router + + paths = {r.path for r in auth_router.router.routes} + assert "/login" not in paths + assert "/register" not in paths + assert "/me" in paths + assert "/vault/unlock" in paths + + +def test_auth_router_mints_no_tokens(): + from app.routers import auth as auth_router + + assert not hasattr(auth_router, "create_access_token") + assert not hasattr(auth_router, "SECRET_KEY") diff --git a/backend/tests/test_identity_vault_models.py b/backend/tests/test_identity_vault_models.py index b2db367..fecb4b9 100644 --- a/backend/tests/test_identity_vault_models.py +++ b/backend/tests/test_identity_vault_models.py @@ -28,8 +28,8 @@ def session(): def _make_user(session, email="john@acme.test", username="john"): - user = User(email=email, username=username, hashed_password="x", - master_key_hash="y") + user = User(email=email, username=username, + fuzefront_user_id=f"ff-{username}", master_key_hash="y") session.add(user) session.commit() return user diff --git a/backend/tests/test_security_regressions.py b/backend/tests/test_security_regressions.py index 865bda7..a330194 100644 --- a/backend/tests/test_security_regressions.py +++ b/backend/tests/test_security_regressions.py @@ -218,11 +218,11 @@ def _seed_two_tenants(session, User, Identity, Account): """Create two users, each with one identity owning one account.""" user_a = User( email="", username="user_a", - hashed_password="h", master_key_hash="m", + fuzefront_user_id="ff-user-a", master_key_hash="m", ) user_b = User( email="", username="user_b", - hashed_password="h", master_key_hash="m", + fuzefront_user_id="ff-user-b", master_key_hash="m", ) session.add_all([user_a, user_b]) session.flush() diff --git a/deploy/helm/fuzekeys/templates/configmap.yaml b/deploy/helm/fuzekeys/templates/configmap.yaml index 89c385b..5556019 100644 --- a/deploy/helm/fuzekeys/templates/configmap.yaml +++ b/deploy/helm/fuzekeys/templates/configmap.yaml @@ -26,6 +26,18 @@ data: REACT_APP_API_URL: {{ .Values.config.apiBaseUrl | quote }} ALLOWED_HOSTS: {{ .Values.config.allowedHosts | default "localhost,127.0.0.1,*.localhost" | quote }} PYTHONPATH: "/app" + # ---- FuzeFront Security API ---- + # This addresses FUZEFRONT'S OWN service. FuzeKeys delegates every AuthN and + # AuthZ decision to it and knows nothing about which identity provider or + # policy engine sits behind it — that is FuzeFront's private implementation. + FUZEFRONT_SECURITY_BASE_URL: {{ .Values.config.fuzefrontSecurityBaseUrl | quote }} + FUZEFRONT_SECURITY_TIMEOUT_SECONDS: {{ .Values.config.fuzefrontSecurityTimeoutSeconds | default 5 | quote }} + {{- with .Values.config.fuzefrontDefaultTenant }} + # Single-tenant deployments only. Leaving this unset means tenant-scoped + # authorization fails CLOSED when a session carries no tenant, which is the + # behaviour the security contract mandates. + FUZEFRONT_DEFAULT_TENANT: {{ . | quote }} + {{- end }} {{- if .Values.vault.enabled }} --- # Vault server config (transit engine, persistent file storage). The image diff --git a/deploy/helm/fuzekeys/values.yaml b/deploy/helm/fuzekeys/values.yaml index cd578ef..9ef6ff9 100644 --- a/deploy/helm/fuzekeys/values.yaml +++ b/deploy/helm/fuzekeys/values.yaml @@ -76,6 +76,19 @@ config: # for the pre-built image; the real value must be baked at image build. apiBaseUrl: "https://api.keys.prod.fuzefront.com" + # FuzeFront Security API — the single source of AuthN and AuthZ for FuzeKeys. + # + # This is FuzeFront's own in-cluster service. It is legitimately platform-side + # config: FuzeKeys must know where FuzeFront is, and nothing more. It must + # NEVER be pointed at an identity provider or a policy-decision point + # directly — that would rebuild exactly the coupling this replaced. + fuzefrontSecurityBaseUrl: "http://fuzefront-backend.fuzefront.svc.cluster.local:3001" + fuzefrontSecurityTimeoutSeconds: 5 + # Optional, single-tenant deployments only. Unset means tenant-scoped + # authorization fails closed when a session carries no tenant — the behaviour + # the security contract requires. Do not set it "to make things work". + fuzefrontDefaultTenant: "" + # ----------------------------------------------------------------------------- # Secrets — FUZEKEYS-OWNED credentials (NOT FuzeInfra's). # diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4629e04..05146f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,48 +1,76 @@ +/** + * Standalone FuzeKeys app (not the Module-Federation remote — see MfeApp.tsx). + * + * Session state comes from `AuthProvider`, which reads the FuzeFront Security + * API. FuzeKeys renders no credential form of its own: `/login` and `/register` + * hand off to FuzeFront's sign-in and sign-up surfaces. + * + * `VaultGate` sits between authentication and the app content because they are + * genuinely different gates — being signed in tells you who you are; the master + * key is what decrypts the vault. + */ + import React from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; import Layout from './components/Layout'; +import VaultGate from './components/VaultGate'; +import { AuthProvider } from './contexts/AuthContext'; import Dashboard from './pages/Dashboard'; import Identities from './pages/Identities'; import Accounts from './pages/Accounts'; import Chat from './pages/Chat'; +import Login from './pages/Login'; +import Register from './pages/Register'; import SitesDatabase from './components/SitesDatabase'; import { GoogleIntegrationPage } from './integrations/google'; import './index.css'; function App() { return ( - -
- - - - {/* Demo Routes - No Authentication Required */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Catch all route */} - } /> - -
-
+ + +
+ + + + {/* Hand-off surfaces — these redirect to FuzeFront, they are not forms. */} + } /> + } /> + + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Catch all route */} + } /> + +
+
+
); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/MfeApp.tsx b/frontend/src/MfeApp.tsx index a717f1a..bb2b6ef 100644 --- a/frontend/src/MfeApp.tsx +++ b/frontend/src/MfeApp.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; +import VaultGate from './components/VaultGate'; +import { AuthProvider } from './contexts/AuthContext'; import Dashboard from './pages/Dashboard'; import Identities from './pages/Identities'; import Accounts from './pages/Accounts'; @@ -12,26 +14,39 @@ import './index.css'; // MFE entry point — loaded by FuzeFront via module federation. // Uses MemoryRouter so nested routes work without conflicting with the host's BrowserRouter. // FuzeFront provides chrome (nav, topbar); this renders only the content area. +// +// There is deliberately NO sign-in route here. In portal mode the user is +// already authenticated by the FuzeFront host and the session token is read via +// the FuzeFront Security API (`GET /v1/security/session`) on the same-origin +// API base. A remote that renders its own login screen inside an already +// authenticated shell is a bug, not a feature. +// +// `VaultGate` still applies: the master key decrypts FuzeKeys' store and is a +// FuzeKeys concern, entirely separate from who the signed-in user is. export default function MfeApp() { return ( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); } diff --git a/frontend/src/components/VaultGate.tsx b/frontend/src/components/VaultGate.tsx new file mode 100644 index 0000000..8fbc24e --- /dev/null +++ b/frontend/src/components/VaultGate.tsx @@ -0,0 +1,131 @@ +/** + * Master-key gate for the encrypted vault. + * + * The master key used to be a third field on the login form, submitted + * alongside email and password. That conflated two unrelated things: proving + * who you are (FuzeFront's job) and decrypting your vault (FuzeKeys' job, and + * the reason this product exists). + * + * Delegating authentication to FuzeFront made that split unavoidable — but the + * CAPABILITY is preserved exactly, not dropped: without the master key, the + * vault stays locked and no secret is decrypted. It just happens after sign-in + * now instead of during it. + * + * `VaultGate` renders its children only once the vault is open, prompting for + * setup (first use) or unlock (every use after) as appropriate. + */ + +import React, { ReactNode, useState } from 'react' + +import { useAuth } from '../contexts/AuthContext' +import { FuzeKeysApiError } from '../services/authService' + +interface VaultGateProps { + children: ReactNode +} + +export const VaultGate: React.FC = ({ children }) => { + const { loading, isAuthenticated, vault, unlockVault, setupVault } = useAuth() + const [masterKey, setMasterKey] = useState('') + const [confirmKey, setConfirmKey] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + if (loading) { + return ( +
+

Loading…

+
+ ) + } + + // Not signed in is not this component's problem — routing sends the user to + // FuzeFront's sign-in surface. + if (!isAuthenticated) return <>{children} + if (vault.vault_unlocked) return <>{children} + + const needsSetup = !vault.vault_initialized + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setError(null) + + if (needsSetup && masterKey !== confirmKey) { + setError('The two master keys do not match.') + return + } + + setBusy(true) + try { + if (needsSetup) await setupVault(masterKey) + else await unlockVault(masterKey) + setMasterKey('') + setConfirmKey('') + } catch (err) { + if (err instanceof FuzeKeysApiError) { + setError(err.message) + } else { + setError('Could not open the vault. Please try again.') + } + } finally { + setBusy(false) + } + } + + return ( +
+
+
+

+ {needsSetup ? 'Set up your vault' : 'Unlock your vault'} +

+

+ {needsSetup + ? 'Choose a master key. It encrypts everything in your vault and is never sent to FuzeFront — if you lose it, the data cannot be recovered.' + : 'Your master key decrypts your stored identities and credentials.'} +

+
+ +
+ setMasterKey(e.target.value)} + /> + + {needsSetup && ( + setConfirmKey(e.target.value)} + /> + )} + + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ) +} + +export default VaultGate diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 7250342..d1612c5 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,78 +1,227 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { authService, User } from '../services/authService'; +/** + * Session state for FuzeKeys. + * + * Before: this context owned a login form's worth of logic — it POSTed + * email + password + master key to FuzeKeys' backend and stashed a + * FuzeKeys-minted JWT in localStorage. + * + * Now: FuzeKeys holds no credentials at all. The session comes from the + * FuzeFront Security API — either inherited from the host shell (portal mode, + * where the user is already signed in) or established by FuzeFront's own + * sign-in surface (standalone mode). This context just reads it, exposes it, + * and hands logout back to FuzeFront. + * + * `vault` is tracked separately and deliberately: the master key unlocks + * FuzeKeys' encrypted store and has nothing to do with who you are. Being + * signed in does NOT mean the vault is open. + */ + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + ReactNode, +} from 'react' + +import { authService, User, VaultStatus } from '../services/authService' +import { + Identity, + SecurityApiError, + createSession, + deleteSession, + exchangeSessionCode, + getSession, + getStoredToken, + setStoredToken, + startSocialLogin, + SocialProvider, +} from '../services/securityClient' interface AuthContextType { - user: User | null; - isAuthenticated: boolean; - loading: boolean; - login: (email: string, password: string, masterKey: string) => Promise; - register: (userData: any) => Promise; - logout: () => void; + /** Normalized FuzeFront identity, or null when not signed in. */ + identity: Identity | null + /** FuzeKeys-local projection of the signed-in principal. */ + user: User | null + isAuthenticated: boolean + loading: boolean + /** Vault (master key) state — independent of being signed in. */ + vault: VaultStatus + /** + * Password sign-in via `POST /v1/security/session`. + * Returns 'mfa_required' when FuzeFront wants step-up; the caller must send + * the user to FuzeFront's sign-in surface to complete it — FuzeKeys does not + * implement MFA challenge UI. + */ + signIn: (email: string, password: string) => Promise<'authenticated' | 'mfa_required'> + /** Social sign-in via `GET /v1/security/social/{provider}/start` (navigates). */ + signInWithProvider: (provider: SocialProvider) => void + /** Exchange a social-callback code via `POST /v1/security/session/exchange`. */ + completeSocialSignIn: (code: string) => Promise<'authenticated' | 'mfa_required'> + /** Revoke the session (FuzeFront `DELETE /v1/security/session`). */ + signOut: () => Promise + unlockVault: (masterKey: string) => Promise + setupVault: (masterKey: string) => Promise + refresh: () => Promise } -const AuthContext = createContext(undefined); +const CLOSED_VAULT: VaultStatus = { vault_initialized: false, vault_unlocked: false } + +const AuthContext = createContext(undefined) -export function useAuth() { - const context = useContext(AuthContext); +export function useAuth(): AuthContextType { + const context = useContext(AuthContext) if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); + throw new Error('useAuth must be used within an AuthProvider') } - return context; + return context } interface AuthProviderProps { - children: ReactNode; + children: ReactNode } export function AuthProvider({ children }: AuthProviderProps) { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); + const [identity, setIdentity] = useState(null) + const [user, setUser] = useState(null) + const [vault, setVault] = useState(CLOSED_VAULT) + const [loading, setLoading] = useState(true) + + const clearSession = useCallback(() => { + setStoredToken(null) + setIdentity(null) + setUser(null) + setVault(CLOSED_VAULT) + }, []) + + /** Hydrate from whatever token we have: FuzeFront first, then FuzeKeys. */ + const hydrate = useCallback(async () => { + if (!getStoredToken()) { + clearSession() + setLoading(false) + return + } + + try { + const session = await getSession() + setIdentity(session.identity) + + // FuzeKeys' own projection: the integer id its vault relations use, plus + // vault status. A failure here is NOT a sign-out — the FuzeFront session + // is valid; it is FuzeKeys that is unhappy. + try { + const [me, vaultStatus] = await Promise.all([ + authService.getCurrentUser(), + authService.getVaultStatus(), + ]) + setUser(me) + setVault(vaultStatus) + } catch { + setUser(null) + setVault(CLOSED_VAULT) + } + } catch (error) { + // Only a genuine rejection clears the session. A 503 means FuzeFront is + // unreachable, which is not evidence the user is signed out. + if (error instanceof SecurityApiError && error.status === 401) { + clearSession() + } else { + setIdentity(null) + setUser(null) + } + } finally { + setLoading(false) + } + }, [clearSession]) useEffect(() => { - // Check if user is already logged in - const token = localStorage.getItem('token'); - if (token) { - authService.getCurrentUser() - .then(setUser) - .catch(() => { - localStorage.removeItem('token'); - }) - .finally(() => setLoading(false)); - } else { - setLoading(false); + void hydrate() + }, [hydrate]) + + const adoptResult = useCallback( + async ( + result: Awaited> + ): Promise<'authenticated' | 'mfa_required'> => { + // Narrow on the discriminator before touching `token` — an MFA-enabled + // account otherwise looks like a successful login with no token. + if (result.status !== 'authenticated') return 'mfa_required' + setStoredToken(result.token) + await hydrate() + return 'authenticated' + }, + [hydrate] + ) + + const signIn = useCallback( + async (email: string, password: string) => adoptResult(await createSession(email, password)), + [adoptResult] + ) + + const completeSocialSignIn = useCallback( + async (code: string) => adoptResult(await exchangeSessionCode(code)), + [adoptResult] + ) + + const signInWithProvider = useCallback((provider: SocialProvider) => { + startSocialLogin(provider) + }, []) + + const signOut = useCallback(async () => { + try { + // FuzeKeys' logout forwards to FuzeFront's revoke, so the token is + // actually invalidated rather than merely forgotten. + await authService.logout() + } catch { + // Best effort — fall back to revoking directly. + try { + await deleteSession() + } catch { + /* the token is discarded either way */ + } + } finally { + clearSession() } - }, []); - - const login = async (email: string, password: string, masterKey: string) => { - const response = await authService.login(email, password, masterKey); - localStorage.setItem('token', response.access_token); - const userData = await authService.getCurrentUser(); - setUser(userData); - }; - - const register = async (userData: any) => { - const newUser = await authService.register(userData); - // After registration, user needs to login - return newUser; - }; - - const logout = () => { - localStorage.removeItem('token'); - setUser(null); - }; - - const value = { - user, - isAuthenticated: !!user, - loading, - login, - register, - logout, - }; - - return ( - - {children} - - ); -} \ No newline at end of file + }, [clearSession]) + + const unlockVault = useCallback(async (masterKey: string) => { + setVault(await authService.unlockVault(masterKey)) + }, []) + + const setupVault = useCallback(async (masterKey: string) => { + setVault(await authService.setupVault(masterKey)) + }, []) + + const value = useMemo( + () => ({ + identity, + user, + isAuthenticated: identity !== null, + loading, + vault, + signIn, + signInWithProvider, + completeSocialSignIn, + signOut, + unlockVault, + setupVault, + refresh: hydrate, + }), + [ + identity, + user, + loading, + vault, + signIn, + signInWithProvider, + completeSocialSignIn, + signOut, + unlockVault, + setupVault, + hydrate, + ] + ) + + return {children} +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 3ebe2a6..360809e 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,67 +1,103 @@ -import React, { useState } from 'react'; +/** + * Sign-in surface — a REDIRECT, not a form. + * + * FuzeKeys used to render its own email + password + master-key form. It no + * longer does, and the deletion is the point: a product that renders a + * credential form has taken ownership of authentication, and every such form + * is another place for password handling, MFA, social login, lockout and reset + * to be got subtly wrong. Authentication is FuzeFront's. + * + * In portal mode (FuzeKeys mounted as a Module-Federation remote) the user is + * already signed in and never sees this. In standalone mode this hands them to + * FuzeFront's sign-in surface and comes back. + * + * NOTE: `@fuzefront/identity-ui` is NOT used here because it does not export a + * sign-in or sign-up component — it is member / invite / API-token MANAGEMENT + * UI (`IdentityPage`, `MembersTable`, `InviteModal`, `TokenList`). There is no + * published FuzeFront package exporting a reusable sign-in screen, so the + * hand-off below is the closest thing to "don't hand-roll an auth screen" that + * the platform currently makes possible. Raised as a contract gap in the PR. + */ + +import React, { useEffect, useState } from 'react' + +import { useAuth } from '../contexts/AuthContext' +import { AuthMethods, getAuthMethods } from '../services/securityClient' + +/** + * Where FuzeFront's sign-in lives. Same-origin by construction: FuzeKeys is + * served from the FuzeFront portal origin in portal mode and behind the same + * ingress in standalone mode, so an absolute host would break under local TLS. + */ +const FUZEFRONT_SIGN_IN_PATH = '/login' + +function redirectToFuzeFront(path: string): void { + const returnTo = encodeURIComponent(window.location.href) + window.location.href = `${path}?redirect=${returnTo}` +} const Login: React.FC = () => { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [masterKey, setMasterKey] = useState(''); + const { isAuthenticated, loading } = useAuth() + const [methods, setMethods] = useState(null) + const [error, setError] = useState(null) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - // Login logic will be implemented - console.log('Login attempt:', { email, password, masterKey }); - }; + useEffect(() => { + // Ask FuzeFront which affordances exist. FuzeKeys does not know or care + // which identity provider is behind them. + getAuthMethods() + .then(setMethods) + .catch(() => setError('Could not reach the sign-in service.')) + }, []) + + useEffect(() => { + if (!loading && !isAuthenticated && methods) { + redirectToFuzeFront(FUZEFRONT_SIGN_IN_PATH) + } + }, [loading, isAuthenticated, methods]) + + if (isAuthenticated) { + return ( +
+

You are signed in.

+
+ ) + } return (
-
-
-

- Sign in to your account -

-
-
-
-
- setEmail(e.target.value)} - /> -
-
- setPassword(e.target.value)} - /> -
-
- setMasterKey(e.target.value)} - /> -
-
+
+

Sign in to FuzeKeys

-
- + + ) : ( + <> +

+ FuzeKeys uses your FuzeFront account. Redirecting you to sign in… +

+ -
- + + )}
- ); -}; + ) +} -export default Login; \ No newline at end of file +export default Login diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index f92e831..a3359b8 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,14 +1,77 @@ -import React from 'react'; +/** + * Sign-up surface — a REDIRECT, not a form. + * + * Account creation is `POST /v1/security/signup` on the FuzeFront Security + * API, driven by FuzeFront's own branded sign-up screen. FuzeKeys does not + * collect an email, a password, or anything else that would make it an + * enrollment surface. + * + * See `Login.tsx` for why `@fuzefront/identity-ui` is not used here. + */ + +import React, { useEffect, useState } from 'react' + +import { useAuth } from '../contexts/AuthContext' +import { AuthMethods, getAuthMethods } from '../services/securityClient' + +const FUZEFRONT_SIGN_UP_PATH = '/signup' + +function redirectToFuzeFront(path: string): void { + const returnTo = encodeURIComponent(window.location.href) + window.location.href = `${path}?redirect=${returnTo}` +} const Register: React.FC = () => { + const { isAuthenticated, loading } = useAuth() + const [methods, setMethods] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + getAuthMethods() + .then(setMethods) + .catch(() => setError('Could not reach the sign-up service.')) + }, []) + + useEffect(() => { + if (!loading && !isAuthenticated && methods) { + redirectToFuzeFront(FUZEFRONT_SIGN_UP_PATH) + } + }, [loading, isAuthenticated, methods]) + + if (isAuthenticated) { + return ( +
+

You already have an account and are signed in.

+
+ ) + } + return ( -
-
-

Register

-

Registration form will be implemented here

+
+
+

Create a FuzeFront account

+ + {error && ( +

+ {error} +

+ )} + +

+ FuzeKeys uses your FuzeFront account. You will set up your encrypted vault + after signing in. +

+ +
- ); -}; + ) +} -export default Register; \ No newline at end of file +export default Register diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index 8b25a4f..93f4043 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -1,83 +1,117 @@ -import axios from 'axios'; +/** + * FuzeKeys' own `/api/v1/auth/*` surface. + * + * This used to be the login/register client: it POSTed an email, a password + * and a master key to FuzeKeys' backend, which minted its own JWT. All of that + * is gone — authentication belongs to the FuzeFront Security API (see + * `securityClient.ts`), and FuzeKeys stores no password. + * + * What remains is the part that was always FuzeKeys' own: the encrypted vault + * and its master key. The master key is a DOMAIN secret — it decrypts the + * vault. It was never an authentication factor; it just happened to be + * collected on the login form. Splitting it out preserves the capability (you + * still cannot read a secret without it) while letting FuzeFront own identity. + */ -const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000/api/v1'; +import { getStoredToken } from './securityClient' + +/** Same-origin, exactly like the security client. Never an absolute host. */ +const API_BASE = '/api/v1' export interface User { - id: number; - username: string; - email: string; - first_name?: string; - last_name?: string; - is_active: boolean; - is_verified: boolean; - created_at: string; + id: number + username: string + email: string + first_name?: string + last_name?: string + is_active: boolean + is_verified: boolean + created_at: string + /** Whether this user has completed vault setup. */ + vault_initialized: boolean } -export interface LoginResponse { - access_token: string; - token_type: string; +export interface VaultStatus { + vault_initialized: boolean + vault_unlocked: boolean } -export interface RegisterData { - username: string; - email: string; - password: string; - master_key: string; - first_name?: string; - last_name?: string; -} +export class FuzeKeysApiError extends Error { + readonly status: number -// Create axios instance with default config -const api = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, -}); + constructor(status: number, message: string) { + super(message) + this.name = 'FuzeKeysApiError' + this.status = status + } +} -// Add token to requests if available -api.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; +async function request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') } - return config; -}); - -// Handle token expiration -api.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('token'); - window.location.href = '/login'; + // The bearer token is the FuzeFront-minted session token. FuzeKeys' backend + // does not verify it locally — it forwards it to GET /v1/security/session. + const token = getStoredToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + + const response = await fetch(`${API_BASE}${path}`, { + ...init, + headers, + credentials: 'same-origin', + }) + + if (response.status === 204) return undefined as T + + const text = await response.text() + let body: any = null + if (text) { + try { + body = JSON.parse(text) + } catch { + body = null } - return Promise.reject(error); } -); + + if (!response.ok) { + throw new FuzeKeysApiError( + response.status, + body?.detail || `Request failed (${response.status})` + ) + } + return body as T +} export const authService = { - async login(email: string, password: string, masterKey: string): Promise { - const response = await api.post('/auth/login', { - email, - password, - master_key: masterKey, - }); - return response.data; + /** `GET /api/v1/auth/me` — the FuzeKeys-local projection of the caller. */ + getCurrentUser(): Promise { + return request('/auth/me') }, - async register(userData: RegisterData): Promise { - const response = await api.post('/auth/register', userData); - return response.data; + /** `POST /api/v1/auth/logout` — delegates to `DELETE /v1/security/session`. */ + logout(): Promise { + return request('/auth/logout', { method: 'POST' }) }, - async getCurrentUser(): Promise { - const response = await api.get('/auth/me'); - return response.data; + /** `GET /api/v1/auth/vault` — is the vault set up, and is it unlocked? */ + getVaultStatus(): Promise { + return request('/auth/vault') }, - async logout(): Promise { - await api.post('/auth/logout'); - localStorage.removeItem('token'); + /** `POST /api/v1/auth/vault/setup` — set the master key for the first time. */ + setupVault(masterKey: string): Promise { + return request('/auth/vault/setup', { + method: 'POST', + body: JSON.stringify({ master_key: masterKey }), + }) }, -}; \ No newline at end of file + + /** `POST /api/v1/auth/vault/unlock` — unlock the vault with the master key. */ + unlockVault(masterKey: string): Promise { + return request('/auth/vault/unlock', { + method: 'POST', + body: JSON.stringify({ master_key: masterKey }), + }) + }, +} diff --git a/frontend/src/services/securityClient.ts b/frontend/src/services/securityClient.ts new file mode 100644 index 0000000..e0abfdc --- /dev/null +++ b/frontend/src/services/securityClient.ts @@ -0,0 +1,337 @@ +/** + * Client for the FuzeFront Security API. + * + * This is the ONLY thing FuzeKeys knows about authentication. There is no + * identity-provider SDK here, no issuer URL, no OIDC client config, no policy + * engine — those are FuzeFront's private implementation details, deliberately + * invisible from a consuming product. + * + * Every path below is taken verbatim from the published contract + * (`@fuzefront/security-client` / `packages/security/openapi.yaml`). Nothing is + * invented: if FuzeKeys ever needs a capability with no operation in that + * contract, the fix is to raise a contract gap with FuzeFront, never to reach + * around it. + * + * WHY NOT `import { ... } from '@fuzefront/security-client'`? The package is + * published to a restricted GitHub Packages registry that this app's build does + * not authenticate against, so it cannot be a dependency yet. The shapes below + * are therefore a hand-mirror of that package's exported types, marked so they + * can be swapped for the real import in one commit once the registry + * credentials are wired (the package also declares a React 19 peer, which this + * app does not yet satisfy — see the PR description). + */ + +// ── Contract types (mirrored from @fuzefront/security-client) ──────────────── + +/** `AuthMode` — provider-neutral token formats. Never a vendor name. */ +export type AuthMode = 'legacy-hs256' | 'federated-jwks' + +/** `SocialProvider` — extensible; `google` is first. */ +export type SocialProvider = 'google' + +export type MfaFactorType = 'totp' | 'sms' | 'email' | 'webauthn' + +/** `Identity` — the contract keystone. */ +export interface Identity { + userId: string + tenantId: string | null + roles: string[] + email?: string + authMode: AuthMode + issuedAt?: number + expiresAt?: number + issuer?: string +} + +export interface SecurityUser { + id: string + email: string + firstName?: string + lastName?: string + roles: string[] +} + +export interface SessionInfo { + identity: Identity + user: SecurityUser +} + +/** `AuthMethods` — capability descriptor driving which affordances to render. */ +export interface AuthMethods { + password: boolean + social: SocialProvider[] + mfa: { enabled: boolean; types: MfaFactorType[] } + verification: { email: boolean; sms: boolean } +} + +/** `SessionResult` — discriminated on `status`. Narrow before reading fields. */ +export type SessionResult = + | { status: 'authenticated'; token: string; sessionId?: string; user: SecurityUser } + | { + status: 'mfa_required' + challengeId: string + factors: { factorId: string; type: MfaFactorType }[] + } + +export interface EmailAvailability { + available: boolean + email: string +} + +export interface AuthzDecision { + allow: boolean +} + +export interface ResourceRef { + type: string + key?: string +} + +/** One `AuthzCheckRequest`, using the bare keys from registration/policy.json. */ +export interface AuthzCheckRequest { + subject: string + tenant: string + resource: ResourceRef + action: string + context?: Record +} + +export class SecurityApiError extends Error { + readonly status: number + readonly code?: string + + constructor(status: number, message: string, code?: string) { + super(message) + this.name = 'SecurityApiError' + this.status = status + this.code = code + } +} + +// ── Transport ──────────────────────────────────────────────────────────────── + +/** + * SAME-ORIGIN base, always. + * + * FuzeKeys runs both as a Module-Federation remote inside the FuzeFront host + * and standalone behind its own ingress. In both cases the Security API is + * reachable on the current origin. Hard-coding an absolute host would break + * under local TLS (mixed content) and would pin the app to one environment. + */ +const SECURITY_BASE = '/v1/security' + +const TOKEN_STORAGE_KEY = 'fuzefront.session.token' + +export function getStoredToken(): string | null { + try { + return window.localStorage.getItem(TOKEN_STORAGE_KEY) + } catch { + return null + } +} + +export function setStoredToken(token: string | null): void { + try { + if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token) + else window.localStorage.removeItem(TOKEN_STORAGE_KEY) + } catch { + /* storage unavailable (private mode); the session simply won't persist */ + } +} + +async function request( + path: string, + init: RequestInit = {}, + { authenticated = false }: { authenticated?: boolean } = {} +): Promise { + const headers = new Headers(init.headers) + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + if (authenticated) { + const token = getStoredToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + } + + const response = await fetch(`${SECURITY_BASE}${path}`, { + ...init, + headers, + credentials: 'same-origin', + }) + + if (response.status === 204) return undefined as T + + let body: any = null + const text = await response.text() + if (text) { + try { + body = JSON.parse(text) + } catch { + body = null + } + } + + if (!response.ok) { + throw new SecurityApiError( + response.status, + body?.message || body?.error || `Security API request failed (${response.status})`, + body?.code + ) + } + + return body as T +} + +// ── Session (AuthN) ────────────────────────────────────────────────────────── + +/** `GET /v1/security/session` — the current identity ("me"). */ +export function getSession(): Promise { + return request('/session', { method: 'GET' }, { authenticated: true }) +} + +/** + * `POST /v1/security/session` — password login. + * + * Returns a `SessionResult`; narrow on `status` before assuming a token, or an + * MFA-enabled account silently appears to log in with `token === undefined`. + */ +export function createSession(email: string, password: string): Promise { + return request('/session', { + method: 'POST', + body: JSON.stringify({ email, password }), + }) +} + +/** `POST /v1/security/session/exchange` — exchange a social-callback code. */ +export function exchangeSessionCode(code: string): Promise { + return request('/session/exchange', { + method: 'POST', + body: JSON.stringify({ code }), + }) +} + +/** `DELETE /v1/security/session` — revoke the current session. */ +export function deleteSession(): Promise { + return request('/session', { method: 'DELETE' }, { authenticated: true }) +} + +/** `POST /v1/security/signup` — server-brokered account creation. */ +export function signup(input: { + email: string + password: string + firstName?: string + lastName?: string + tenantName?: string +}): Promise<{ token: string; sessionId?: string; user: SecurityUser }> { + return request('/signup', { method: 'POST', body: JSON.stringify(input) }) +} + +/** `GET /v1/security/methods` — which auth affordances to render. */ +export function getAuthMethods(): Promise { + return request('/methods', { method: 'GET' }) +} + +/** `GET /v1/security/email-available` — inline signup-form availability check. */ +export function getEmailAvailable(email: string): Promise { + return request( + `/email-available?email=${encodeURIComponent(email)}`, + { method: 'GET' } + ) +} + +/** `POST /v1/security/session/password/reset-request` — always 202, no enumeration. */ +export function requestPasswordReset(email: string): Promise { + return request('/session/password/reset-request', { + method: 'POST', + body: JSON.stringify({ email }), + }) +} + +/** + * `GET /v1/security/social/{provider}/start` — begin social login. + * + * A 302 the browser must follow, so this navigates rather than fetches. + * FuzeKeys does not know which providers exist; it renders whatever + * `getAuthMethods().social` reports. + */ +export function startSocialLogin(provider: SocialProvider, redirectTo?: string): void { + const target = redirectTo ?? window.location.href + window.location.href = `${SECURITY_BASE}/social/${encodeURIComponent( + provider + )}/start?redirect_uri=${encodeURIComponent(target)}` +} + +// ── AuthZ ──────────────────────────────────────────────────────────────────── + +/** + * `POST /v1/security/authz/check` — one decision. Fail-closed. + * + * UI-side checks are for AFFORDANCES only (hide a button the user cannot use). + * The backend re-checks every decision; nothing here is a security boundary. + */ +export async function authzCheck( + identity: Identity, + resource: ResourceRef, + action: string +): Promise { + if (!identity.tenantId) return false // contract: fail closed with no tenant + try { + const decision = await request( + '/authz/check', + { + method: 'POST', + body: JSON.stringify({ + subject: identity.userId, + tenant: identity.tenantId, + resource, + action, + }), + }, + { authenticated: true } + ) + return decision?.allow === true + } catch { + return false + } +} + +/** + * `POST /v1/security/authz/bulk-check` — index-aligned decisions. Fail-closed. + * + * A length mismatch denies everything rather than mis-aligning decisions onto + * the wrong resources. + */ +export async function authzBulkCheck( + identity: Identity, + checks: { resource: ResourceRef; action: string }[] +): Promise { + if (checks.length === 0) return [] + const denyAll = checks.map(() => false) + if (!identity.tenantId) return denyAll + if (checks.length > 200) { + throw new Error('authz/bulk-check accepts at most 200 checks per call') + } + + try { + const body = await request<{ decisions: AuthzDecision[] }>( + '/authz/bulk-check', + { + method: 'POST', + body: JSON.stringify({ + checks: checks.map(c => ({ + subject: identity.userId, + tenant: identity.tenantId, + resource: c.resource, + action: c.action, + })), + }), + }, + { authenticated: true } + ) + const decisions = body?.decisions + if (!Array.isArray(decisions) || decisions.length !== checks.length) return denyAll + return decisions.map(d => d?.allow === true) + } catch { + return denyAll + } +} diff --git a/registration/README.md b/registration/README.md index 370b7e1..d78115d 100644 --- a/registration/README.md +++ b/registration/README.md @@ -5,7 +5,7 @@ FuzeKeys self-registers with the FuzeFront portal at deploy time. | File | Purpose | |---|---| | `manifest.json` | App identity, Module-Federation contract, `nav` placement | -| `policy.json` | FuzeKeys' own Permit resources/roles, bare keys | +| `policy.json` | FuzeKeys' own resources/roles, BARE keys — no engine-specific identifiers | | `register.sh` | Idempotent registration script from `@fuzefront/onboarding-kit` | ## Module Federation contract @@ -34,6 +34,12 @@ flow. ## Policy — reading a secret is not a read +These keys are consumed by FuzeFront's authorization API +(`POST /v1/security/authz/check`, `resource: { type }` + `action`). They are +deliberately BARE — FuzeKeys names a resource and an action and nothing else. +Which policy engine evaluates them is FuzeFront's private implementation detail +and never appears in this repo. + Derived from `backend/app/models/`: `Identity`, `Account`, `VaultAsset` (the `identity_cards` + `api_credentials` tables), `Site`, `SignupScript`, `ApiKey`.