feat(04-rbac-01): DB migration 006 + RBAC ORM models + config fields

- Migration 006: adds role TEXT+CHECK column to portal_users, backfills
  is_admin -> platform_admin/customer_admin, drops is_admin
- Migration 006: creates user_tenant_roles table (UNIQUE user_id+tenant_id)
- Migration 006: creates portal_invitations table with token_hash, status, expires_at
- PortalUser: replaced is_admin (bool) with role (str, default customer_admin)
- Added UserRole enum (PLATFORM_ADMIN, CUSTOMER_ADMIN, CUSTOMER_OPERATOR)
- Added UserTenantRole ORM model with FK cascade deletes
- Added PortalInvitation ORM model with token_hash unique constraint
- Settings: added invite_secret, smtp_host, smtp_port, smtp_username,
  smtp_password, smtp_from_email fields
This commit is contained in:
2026-03-24 13:49:16 -06:00
parent 2aecc5c787
commit f710c9c5fe
3 changed files with 377 additions and 7 deletions

View File

@@ -120,6 +120,34 @@ class Settings(BaseSettings):
default="insecure-dev-secret-change-in-production",
description="Secret key for signing JWT tokens",
)
invite_secret: str = Field(
default="insecure-invite-secret-change-in-production",
description="HMAC secret for signing invite tokens (separate from auth_secret)",
)
# -------------------------------------------------------------------------
# SMTP (for invitation emails)
# -------------------------------------------------------------------------
smtp_host: str = Field(
default="localhost",
description="SMTP server hostname",
)
smtp_port: int = Field(
default=587,
description="SMTP server port",
)
smtp_username: str = Field(
default="",
description="SMTP authentication username",
)
smtp_password: str = Field(
default="",
description="SMTP authentication password",
)
smtp_from_email: str = Field(
default="noreply@konstruct.dev",
description="From address for outbound emails",
)
# -------------------------------------------------------------------------
# Service URLs

View File

@@ -7,23 +7,38 @@ Passwords are stored as bcrypt hashes — never plaintext.
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from shared.models.tenant import Base
class UserRole(str, enum.Enum):
"""
Platform-level role enum for portal_users and user_tenant_roles.
PLATFORM_ADMIN: Full access to all tenants and platform settings.
CUSTOMER_ADMIN: Admin within their own tenant(s); manages agents, keys, billing.
CUSTOMER_OPERATOR: Read-only + send-message access within their tenant(s).
"""
PLATFORM_ADMIN = "platform_admin"
CUSTOMER_ADMIN = "customer_admin"
CUSTOMER_OPERATOR = "customer_operator"
class PortalUser(Base):
"""
An operator with access to the Konstruct admin portal.
RLS is NOT applied to this table — users are authenticated before
tenant context is established. Authorization is handled at the
application layer (is_admin flag + JWT claims).
application layer (role field + JWT claims).
"""
__tablename__ = "portal_users"
@@ -40,11 +55,11 @@ class PortalUser(Base):
comment="bcrypt hash — never store plaintext",
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(
Boolean,
role: Mapped[str] = mapped_column(
String(50),
nullable=False,
default=False,
comment="True for platform-level admin; tenant managers use RBAC",
default="customer_admin",
comment="platform_admin | customer_admin | customer_operator",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@@ -59,4 +74,111 @@ class PortalUser(Base):
)
def __repr__(self) -> str:
return f"<PortalUser id={self.id} email={self.email!r} is_admin={self.is_admin}>"
return f"<PortalUser id={self.id} email={self.email!r} role={self.role!r}>"
class UserTenantRole(Base):
"""
Maps a portal user to a tenant with a specific role.
A user can have at most one role per tenant (UNIQUE constraint).
platform_admin users typically have no rows here — they bypass
tenant membership checks entirely.
"""
__tablename__ = "user_tenant_roles"
__table_args__ = (
UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant_role"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("portal_users.id", ondelete="CASCADE"),
nullable=False,
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
role: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="customer_admin | customer_operator",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
def __repr__(self) -> str:
return f"<UserTenantRole user={self.user_id} tenant={self.tenant_id} role={self.role!r}>"
class PortalInvitation(Base):
"""
Invitation record for invite-only onboarding flow.
token_hash stores SHA-256(raw_token) for secure lookup without exposing
the raw token in the DB. The raw token is returned to the inviter in the
API response and sent via email.
"""
__tablename__ = "portal_invitations"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
role: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="customer_admin | customer_operator",
)
invited_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("portal_users.id"),
nullable=True,
comment="ID of the user who created the invitation",
)
token_hash: Mapped[str] = mapped_column(
String(255),
nullable=False,
unique=True,
comment="SHA-256 hex digest of the raw HMAC invite token",
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
comment="pending | accepted | revoked",
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
def __repr__(self) -> str:
return (
f"<PortalInvitation id={self.id} email={self.email!r} "
f"status={self.status!r} tenant={self.tenant_id}>"
)