Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/agents/backend-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<svc>-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`).

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/nightly-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
8 changes: 5 additions & 3 deletions .semgrep/fuze-authz.yml
Original file line number Diff line number Diff line change
@@ -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)"
Expand All @@ -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(...)"
Expand All @@ -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(...)"
Expand Down
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
10 changes: 9 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
34 changes: 28 additions & 6 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading