---
phase: 04-rbac
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - packages/shared/shared/models/auth.py
  - packages/shared/shared/api/rbac.py
  - packages/shared/shared/api/invitations.py
  - packages/shared/shared/api/portal.py
  - packages/shared/shared/config.py
  - packages/shared/shared/invite_token.py
  - packages/shared/shared/email.py
  - packages/orchestrator/orchestrator/tasks.py
  - migrations/versions/006_rbac_roles.py
  - tests/unit/test_rbac_guards.py
  - tests/unit/test_invitations.py
  - tests/unit/test_portal_auth.py
autonomous: true
requirements:
  - RBAC-01
  - RBAC-02
  - RBAC-03
  - RBAC-04
  - RBAC-06

must_haves:
  truths:
    - "Platform admin caller gets 200 on any tenant endpoint; non-admin gets 403"
    - "Customer admin gets 200 on their own tenant endpoints; gets 403 on other tenants"
    - "Customer operator gets 403 on mutating endpoints; gets 200 on read-only endpoints"
    - "Invite token with valid HMAC and unexpired timestamp validates successfully"
    - "Invite token with tampered signature or expired timestamp raises ValueError"
    - "Auth verify response returns role + tenant_ids instead of is_admin"
  artifacts:
    - path: "packages/shared/shared/api/rbac.py"
      provides: "FastAPI RBAC guard dependencies"
      exports: ["PortalCaller", "get_portal_caller", "require_platform_admin", "require_tenant_admin", "require_tenant_member"]
    - path: "packages/shared/shared/api/invitations.py"
      provides: "Invitation CRUD API router"
      exports: ["invitations_router"]
    - path: "packages/shared/shared/invite_token.py"
      provides: "HMAC token generation and validation"
      exports: ["generate_invite_token", "validate_invite_token"]
    - path: "packages/shared/shared/email.py"
      provides: "SMTP email sender for invitations"
      exports: ["send_invite_email"]
    - path: "migrations/versions/006_rbac_roles.py"
      provides: "Alembic migration adding role enum, user_tenant_roles, portal_invitations"
      contains: "user_tenant_roles"
    - path: "tests/unit/test_rbac_guards.py"
      provides: "Unit tests for RBAC guard dependencies"
      min_lines: 50
    - path: "tests/unit/test_invitations.py"
      provides: "Unit tests for HMAC token and invitation flow"
      min_lines: 40
    - path: "tests/unit/test_portal_auth.py"
      provides: "Unit tests for auth/verify endpoint returning role + tenant_ids claims"
      min_lines: 30
  key_links:
    - from: "packages/shared/shared/api/rbac.py"
      to: "packages/shared/shared/models/auth.py"
      via: "imports UserTenantRole for membership check"
      pattern: "from shared\\.models\\.auth import.*UserTenantRole"
    - from: "packages/shared/shared/api/invitations.py"
      to: "packages/shared/shared/invite_token.py"
      via: "generates and validates HMAC tokens"
      pattern: "from shared\\.invite_token import"
    - from: "packages/shared/shared/api/portal.py"
      to: "packages/shared/shared/models/auth.py"
      via: "auth/verify returns role + tenant_ids"
      pattern: "role.*tenant_ids"
---

<objective>
Backend RBAC foundation: DB schema migration, ORM models, FastAPI guard dependencies, invitation system (API + HMAC tokens + SMTP email), and auth/verify endpoint update.

Purpose: All backend authorization primitives must exist before the portal can implement role-based UI or endpoint guards can be wired to existing routes.
Output: Migration 006, RBAC models, guard dependencies, invitation API, SMTP email utility, updated auth/verify response, unit tests.
</objective>

<execution_context>
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-rbac/04-CONTEXT.md
@.planning/phases/04-rbac/04-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From packages/shared/shared/models/auth.py:
```python
class PortalUser(Base):
    __tablename__ = "portal_users"
    id: Mapped[uuid.UUID]
    email: Mapped[str]  # unique, indexed
    hashed_password: Mapped[str]
    name: Mapped[str]
    is_admin: Mapped[bool]  # TO BE REPLACED by role enum
    created_at: Mapped[datetime]
    updated_at: Mapped[datetime]
```

From packages/shared/shared/api/portal.py:
```python
class AuthVerifyResponse(BaseModel):
    id: str
    email: str
    name: str
    is_admin: bool  # TO BE REPLACED by role + tenant_ids + active_tenant_id

portal_router = APIRouter(prefix="/api/portal", tags=["portal"])
```

From packages/shared/shared/config.py:
```python
class Settings(BaseSettings):
    # Auth / Security section — add invite_secret and SMTP settings here
    auth_secret: str = Field(default="insecure-dev-secret-change-in-production")
```

From packages/shared/shared/db.py:
```python
async def get_session() -> AsyncGenerator[AsyncSession, None]: ...
```

From packages/shared/shared/models/tenant.py:
```python
class Base(DeclarativeBase): ...  # All ORM models inherit from this
class Tenant(Base):
    __tablename__ = "tenants"
    id: Mapped[uuid.UUID]
    name: Mapped[str]
```

From packages/shared/shared/models/audit.py:
```python
class AuditEvent(Base):  # Reuse for impersonation logging in Plan 03
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: DB migration + ORM models for RBAC</name>
  <files>
    migrations/versions/006_rbac_roles.py,
    packages/shared/shared/models/auth.py,
    packages/shared/shared/config.py
  </files>
  <behavior>
    - Migration adds role column to portal_users with CHECK constraint (platform_admin, customer_admin, customer_operator)
    - Migration backfills: is_admin=True -> platform_admin, is_admin=False -> customer_admin
    - Migration drops is_admin column after backfill
    - Migration creates user_tenant_roles table with user_id FK, tenant_id FK, role TEXT, unique(user_id, tenant_id)
    - Migration creates portal_invitations table with email, name, tenant_id FK, role, invited_by FK, token_hash (unique), status, expires_at
    - UserTenantRole ORM model exists with proper ForeignKeys
    - PortalInvitation ORM model exists with proper ForeignKeys
    - UserRole str enum has PLATFORM_ADMIN, CUSTOMER_ADMIN, CUSTOMER_OPERATOR
    - PortalUser.is_admin replaced by PortalUser.role
    - Settings has invite_secret, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from_email fields
  </behavior>
  <action>
    Create Alembic migration `006_rbac_roles.py` (revision 006, depends on 005):
    1. Add `role` column to `portal_users` as TEXT, nullable initially
    2. Execute UPDATE: `is_admin = TRUE -> 'platform_admin'`, else `'customer_admin'`
    3. ALTER to NOT NULL
    4. Add CHECK constraint `ck_portal_users_role CHECK (role IN ('platform_admin', 'customer_admin', 'customer_operator'))` — use TEXT+CHECK pattern per Phase 1 decision (not sa.Enum to avoid DDL issues)
    5. Drop `is_admin` column
    6. CREATE `user_tenant_roles` table: id UUID PK default gen_random_uuid(), user_id UUID FK portal_users(id) ON DELETE CASCADE, tenant_id UUID FK tenants(id) ON DELETE CASCADE, role TEXT NOT NULL, created_at TIMESTAMPTZ default now(), UNIQUE(user_id, tenant_id)
    7. CREATE `portal_invitations` table: id UUID PK default gen_random_uuid(), email VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, tenant_id UUID FK tenants(id) ON DELETE CASCADE, role TEXT NOT NULL, invited_by UUID FK portal_users(id), token_hash VARCHAR(255) NOT NULL UNIQUE, status VARCHAR(20) NOT NULL DEFAULT 'pending', expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ default now()

    Update `packages/shared/shared/models/auth.py`:
    - Add `UserRole(str, enum.Enum)` with PLATFORM_ADMIN, CUSTOMER_ADMIN, CUSTOMER_OPERATOR
    - Replace `is_admin: Mapped[bool]` with `role: Mapped[str] = mapped_column(String(50), nullable=False, default="customer_admin")`
    - Add `UserTenantRole(Base)` model with __tablename__ = "user_tenant_roles", UniqueConstraint("user_id", "tenant_id"), ForeignKey refs
    - Add `PortalInvitation(Base)` model with __tablename__ = "portal_invitations", all fields matching migration

    Update `packages/shared/shared/config.py` — add to Settings:
    - `invite_secret: str = Field(default="insecure-invite-secret-change-in-production")`
    - `smtp_host: str = Field(default="localhost")`
    - `smtp_port: int = Field(default=587)`
    - `smtp_username: str = Field(default="")`
    - `smtp_password: str = Field(default="")`
    - `smtp_from_email: str = Field(default="noreply@konstruct.dev")`
    - `portal_base_url: str = Field(default="http://localhost:3000")` (for invite link URL construction, only add if portal_url doesn't already exist — check existing field name)

    Note: `portal_url` already exists in Settings (used for Stripe redirects). Reuse it for invite links — do NOT add a duplicate field.
  </action>
  <verify>
    <automated>cd /home/adelorenzo/repos/konstruct && python -c "from shared.models.auth import PortalUser, UserTenantRole, PortalInvitation, UserRole; print('Models OK'); assert hasattr(PortalUser, 'role'); assert not hasattr(PortalUser, 'is_admin')"</automated>
  </verify>
  <done>Migration 006 exists with upgrade/downgrade. PortalUser has role (not is_admin). UserTenantRole and PortalInvitation models exist. Settings has invite_secret and SMTP fields.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: RBAC guard dependencies + invite token + email utility + invitation API</name>
  <files>
    packages/shared/shared/api/rbac.py,
    packages/shared/shared/invite_token.py,
    packages/shared/shared/email.py,
    packages/shared/shared/api/invitations.py,
    packages/shared/shared/api/portal.py,
    packages/gateway/gateway/main.py
  </files>
  <behavior>
    - require_platform_admin raises 403 for non-platform_admin callers
    - require_platform_admin returns PortalCaller for platform_admin
    - require_tenant_admin raises 403 for operators and non-members
    - require_tenant_admin allows platform_admin to bypass tenant membership check
    - require_tenant_member allows all three roles if they have tenant membership (or are platform_admin)
    - require_tenant_member raises 403 for users with no membership in the target tenant
    - generate_invite_token produces a base64url-encoded HMAC-signed token
    - validate_invite_token rejects tampered signatures (ValueError)
    - validate_invite_token rejects expired tokens (ValueError)
    - validate_invite_token returns invitation_id for valid tokens
    - POST /api/portal/invitations creates invitation, returns 201
    - POST /api/portal/invitations/accept accepts invitation, creates user, returns 200
    - POST /api/portal/invitations/{id}/resend resets expiry and returns new token
    - AuthVerifyResponse returns role, tenant_ids, active_tenant_id instead of is_admin
  </behavior>
  <action>
    Create `packages/shared/shared/api/rbac.py`:
    - `PortalCaller` dataclass with user_id (UUID), role (str), tenant_id (UUID | None)
    - `get_portal_caller()` — reads X-Portal-User-Id, X-Portal-User-Role, X-Portal-Tenant-Id headers. Returns PortalCaller. Raises 401 on invalid user_id format.
    - `require_platform_admin(caller: PortalCaller)` — raises 403 if role != "platform_admin"
    - `require_tenant_admin(tenant_id: UUID, caller: PortalCaller, session: AsyncSession)` — platform_admin bypasses (return immediately). customer_admin checks UserTenantRole membership. Others get 403.
    - `require_tenant_member(tenant_id: UUID, caller: PortalCaller, session: AsyncSession)` — platform_admin bypasses. customer_admin or customer_operator checks UserTenantRole membership. Returns PortalCaller.

    Create `packages/shared/shared/invite_token.py`:
    - `generate_invite_token(invitation_id: str) -> str` — HMAC-SHA256 with settings.invite_secret, embeds invitation_id:timestamp, base64url-encodes result
    - `validate_invite_token(token: str) -> str` — decodes, verifies HMAC with hmac.compare_digest (timing-safe), checks 48h TTL, returns invitation_id. Raises ValueError on tamper or expiry.
    - Uses `from shared.config import settings` for invite_secret

    Create `packages/shared/shared/email.py`:
    - `send_invite_email(to_email, invitee_name, tenant_name, invite_url)` — sync function using smtplib + email.mime. Subject: "You've been invited to join {tenant_name} on Konstruct". Reads SMTP config from settings. Called from Celery task.

    Create `packages/shared/shared/api/invitations.py`:
    - `invitations_router = APIRouter(prefix="/api/portal/invitations", tags=["invitations"])`
    - POST `/` — accepts {email, name, role, tenant_id}. Requires tenant admin (use require_tenant_admin). Creates PortalInvitation row, generates token, stores SHA-256 hash of token in token_hash column, dispatches Celery email task (fire-and-forget), returns invitation details + raw token in response (for display/copy in UI). Returns 201.
    - POST `/accept` — accepts {token, password}. Validates token via validate_invite_token. Looks up invitation by id, checks status='pending' and not expired. Creates PortalUser with bcrypt password + role from invitation. Creates UserTenantRole row. Updates invitation status='accepted'. All in one transaction. Returns 200 with user details.
    - POST `/{invitation_id}/resend` — requires tenant admin. Generates new token, updates token_hash and expires_at (+48h from now), re-dispatches email Celery task. Returns 200.
    - GET `/` — requires tenant admin. Lists pending invitations for the caller's active tenant. Returns list.

    Add Celery task in orchestrator or define inline:
    - Add `send_invite_email_task` to an appropriate location. Per codebase pattern, Celery tasks are sync def. The task calls `send_invite_email()` directly. If SMTP is not configured (empty smtp_host), log a warning and skip — do not crash.

    Update `packages/shared/shared/api/portal.py`:
    - Change `AuthVerifyResponse` to return `role: str`, `tenant_ids: list[str]`, `active_tenant_id: str | None` instead of `is_admin: bool`
    - Update `verify_credentials` endpoint: after finding user, query `user_tenant_roles` for all tenant_ids where user has membership. For platform_admin, return all tenant IDs (query tenants table). Return role + tenant_ids + active_tenant_id (first tenant or None).
    - Update `AuthRegisterResponse` similarly (replace is_admin with role)
    - Gate `/auth/register` behind platform_admin only (add Depends(require_platform_admin)) with a deprecation comment noting invite-only is the standard flow.

    Mount invitations_router in `packages/gateway/gateway/main.py`:
    - `from shared.api.invitations import invitations_router`
    - `app.include_router(invitations_router)`
  </action>
  <verify>
    <automated>cd /home/adelorenzo/repos/konstruct && python -c "from shared.api.rbac import PortalCaller, require_platform_admin, require_tenant_admin, require_tenant_member; from shared.invite_token import generate_invite_token, validate_invite_token; from shared.api.invitations import invitations_router; print('All imports OK')"</automated>
  </verify>
  <done>RBAC guards raise 403 for unauthorized callers. Invite tokens are HMAC-signed with 48h TTL. Invitation API supports create/accept/resend/list. Auth verify returns role+tenant_ids. Invitations router mounted on gateway.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: Unit tests for RBAC guards, invitation system, and portal auth</name>
  <files>
    tests/unit/test_rbac_guards.py,
    tests/unit/test_invitations.py,
    tests/unit/test_portal_auth.py
  </files>
  <behavior>
    - test_platform_admin_passes: platform_admin caller gets through require_platform_admin
    - test_non_admin_rejected: customer_admin and customer_operator get 403 from require_platform_admin
    - test_tenant_admin_own_tenant: customer_admin with membership passes require_tenant_admin
    - test_tenant_admin_other_tenant: customer_admin without membership gets 403
    - test_platform_admin_bypasses_tenant_check: platform_admin passes require_tenant_admin without membership
    - test_operator_rejected_from_admin: customer_operator gets 403 from require_tenant_admin
    - test_tenant_member_all_roles: all three roles with membership pass require_tenant_member
    - test_token_roundtrip: generate then validate returns same invitation_id
    - test_token_tamper_rejected: modified token raises ValueError
    - test_token_expired_rejected: token older than 48h raises ValueError
    - test_invite_accept_creates_user: accepting invite creates PortalUser + UserTenantRole
    - test_invite_accept_rejects_expired: expired invitation returns error
    - test_invite_resend_updates_token: resend generates new token_hash and extends expires_at
    - test_auth_verify_returns_role: auth/verify response contains role field (not is_admin)
    - test_auth_verify_returns_tenant_ids: auth/verify response contains tenant_ids list
    - test_auth_verify_returns_active_tenant: auth/verify response contains active_tenant_id
  </behavior>
  <action>
    Create `tests/unit/test_rbac_guards.py`:
    - Test require_platform_admin with PortalCaller(role="platform_admin") — should return caller
    - Test require_platform_admin with PortalCaller(role="customer_admin") — should raise HTTPException 403
    - Test require_platform_admin with PortalCaller(role="customer_operator") — should raise HTTPException 403
    - Test require_tenant_admin: mock session with UserTenantRole row for customer_admin — should pass
    - Test require_tenant_admin: mock session with no membership — should raise 403
    - Test require_tenant_admin: platform_admin caller — should bypass membership check entirely
    - Test require_tenant_member: customer_operator with membership — should pass
    - Test require_tenant_member: customer_admin with membership — should pass
    - Test require_tenant_member: no membership — should raise 403

    For tenant membership tests, mock the AsyncSession.execute() to return appropriate results.
    Use `pytest.raises(HTTPException)` and assert `.status_code == 403`.

    Create `tests/unit/test_invitations.py`:
    - Test generate_invite_token + validate_invite_token roundtrip with a UUID string
    - Test validate_invite_token with a manually tampered signature — should raise ValueError
    - Test validate_invite_token with an artificially old timestamp (patch time.time to simulate expiry) — should raise ValueError
    - Test invitation creation via httpx TestClient hitting POST /api/portal/invitations (mock DB session, mock Celery task)
    - Test invitation acceptance via POST /api/portal/invitations/accept (mock DB session with pending invitation)
    - Test resend updates token_hash and extends expires_at

    Create `tests/unit/test_portal_auth.py`:
    - Test the updated auth/verify endpoint returns role (not is_admin) in the response
    - Test auth/verify returns tenant_ids as a list of UUID strings for a user with UserTenantRole memberships
    - Test auth/verify returns active_tenant_id as the first tenant ID (or None for users with no memberships)
    - Test auth/verify for platform_admin returns all tenant IDs from the tenants table
    - Test auth/verify for customer_admin returns only tenant IDs from their UserTenantRole rows
    - Mock the DB session with appropriate PortalUser (role field) and UserTenantRole rows
    - Use httpx.AsyncClient with the app, following existing test patterns (make_app(session) factory)

    Follow existing test patterns: use `make_app(session)` factory from tests/ or direct httpx.AsyncClient with app.
  </action>
  <verify>
    <automated>cd /home/adelorenzo/repos/konstruct && pytest tests/unit/test_rbac_guards.py tests/unit/test_invitations.py tests/unit/test_portal_auth.py -x -v</automated>
  </verify>
  <done>All RBAC guard unit tests pass. All invitation token and API unit tests pass. All portal auth unit tests pass verifying role + tenant_ids claims. Coverage includes platform_admin bypass, tenant membership checks, token tampering, expiry validation, and auth/verify response shape.</done>
</task>

</tasks>

<verification>
- `pytest tests/unit/test_rbac_guards.py tests/unit/test_invitations.py tests/unit/test_portal_auth.py -x -v` — all pass
- `python -c "from shared.models.auth import UserRole; assert len(UserRole) == 3"` — enum has 3 values
- `python -c "from shared.api.rbac import require_platform_admin"` — guard imports clean
- `python -c "from shared.invite_token import generate_invite_token, validate_invite_token"` — token utils import clean
</verification>

<success_criteria>
- Migration 006 exists and handles is_admin -> role backfill
- PortalUser model has role field, no is_admin
- UserTenantRole and PortalInvitation models exist
- RBAC guards (require_platform_admin, require_tenant_admin, require_tenant_member) implemented
- Invitation API (create, accept, resend, list) endpoints exist
- HMAC invite tokens generate and validate with 48h TTL
- SMTP email utility exists (sync, for Celery)
- Auth/verify returns role + tenant_ids
- All unit tests pass (including test_portal_auth.py for JWT callback claims)
</success_criteria>

<output>
After completion, create `.planning/phases/04-rbac/04-01-SUMMARY.md`
</output>
