361 lines
21 KiB
Markdown
361 lines
21 KiB
Markdown
---
|
|
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>
|