Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ S3_DOWNLOAD_URL_TTL_SECONDS=3600
S3_MAX_UPLOAD_BYTES=26214400

CORS_ALLOWED_ORIGINS=http://localhost:3000

INITIAL_OWNER_EMAIL=
INVITATION_TTL_HOURS=336
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ cp .env.template .env # defaults already match docker-compose
just install
just up # postgres + redis + localstack
just migrate
just seed # roles, permissions, first owner invitation
just dev # http://localhost:8000
```

Expand All @@ -38,6 +39,7 @@ Docker compose uses offset host ports so it does not collide with anything alrea
| `just migrate` | apply migrations |
| `just rollback` | undo the last migration |
| `just revision m` | create a migration |
| `just seed` | sync roles and permissions |
| `just test` | pytest |
| `just lint` | ruff check + format check |
| `just fmt` | ruff autofix + format |
Expand Down
132 changes: 132 additions & 0 deletions backend/alembic/versions/0003_identity_and_rbac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""users, roles, invitations and access requests

Revision ID: 0003
Revises: 0002
"""

from collections.abc import Sequence

from alembic import op

revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

UPGRADE = """
CREATE TABLE permissions (
key VARCHAR(128) PRIMARY KEY,
description VARCHAR(255) NOT NULL
);

CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_key VARCHAR(128) NOT NULL REFERENCES permissions(key) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_key)
);

CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entra_object_id UUID NOT NULL UNIQUE,
email VARCHAR(320) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
status VARCHAR(64) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended')),
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_users_email ON users (email);

CREATE TABLE user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
UNIQUE (user_id, role_id)
);

CREATE INDEX ix_user_roles_user_id ON user_roles (user_id);

CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(320) NOT NULL,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_invitations_email ON invitations (email);

CREATE UNIQUE INDEX uq_invitations_open_email ON invitations (email)
WHERE accepted_at IS NULL AND revoked_at IS NULL;

CREATE TABLE access_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(320) NOT NULL,
entra_object_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
message VARCHAR(1000),
status VARCHAR(64) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'denied')),
reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
decision_note VARCHAR(1000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_access_requests_email ON access_requests (email);

CREATE UNIQUE INDEX uq_access_requests_open_email ON access_requests (email)
WHERE status = 'pending';

ALTER TABLE audit_logs
ADD CONSTRAINT fk_audit_logs_actor
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL;

CREATE TRIGGER trg_roles_updated_at BEFORE UPDATE ON roles
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_invitations_updated_at BEFORE UPDATE ON invitations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_access_requests_updated_at BEFORE UPDATE ON access_requests
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
"""

DOWNGRADE = """
ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS fk_audit_logs_actor;
DROP TABLE IF EXISTS access_requests;
DROP TABLE IF EXISTS invitations;
DROP TABLE IF EXISTS user_roles;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS role_permissions;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS permissions;
"""


def upgrade() -> None:
op.execute(UPGRADE)


def downgrade() -> None:
op.execute(DOWNGRADE)
164 changes: 155 additions & 9 deletions backend/src/generate_admin/api/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass
from typing import Annotated

import asyncpg
Expand All @@ -7,15 +8,43 @@

from generate_admin.core.cache import Cache as CacheProtocol
from generate_admin.core.config import Settings, get_settings
from generate_admin.core.errors import (
AccountNotProvisionedError,
AccountSuspendedError,
PermissionDeniedError,
)
from generate_admin.core.security import TokenVerifier
from generate_admin.core.storage import S3Storage
from generate_admin.schemas.session import Identity
from generate_admin.domain.access import PermissionSet, ResolvedAccess
from generate_admin.domain.enums import AccessState
from generate_admin.domain.permissions import Permission
from generate_admin.repositories import (
AccessRequestRepository,
AuditRepository,
InvitationRepository,
RoleRepository,
UserRepository,
)
from generate_admin.schemas.session import Identity, Session
from generate_admin.schemas.user import UserRead
from generate_admin.services.access import AccessService
from generate_admin.services.access_request import AccessRequestService
from generate_admin.services.invitation import InvitationService
from generate_admin.services.member import MemberService

bearer_scheme = HTTPBearer(auto_error=True)

PROVISIONED_STATES = frozenset({AccessState.ACTIVE, AccessState.NO_ROLES})


@dataclass(frozen=True, slots=True)
class AuthContext:
identity: Identity
user: UserRead
permissions: PermissionSet


async def get_connection(request: Request) -> AsyncIterator[asyncpg.Connection]:
"""One connection and one transaction per request; rolls back on error."""
pool: asyncpg.Pool = request.app.state.pool
async with pool.acquire() as connection, connection.transaction():
yield connection
Expand All @@ -39,6 +68,79 @@ def get_token_verifier(request: Request) -> TokenVerifier:
AppSettings = Annotated[Settings, Depends(get_settings)]


def get_user_repository(connection: Connection) -> UserRepository:
return UserRepository(connection)


def get_role_repository(connection: Connection) -> RoleRepository:
return RoleRepository(connection)


def get_invitation_repository(connection: Connection) -> InvitationRepository:
return InvitationRepository(connection)


def get_access_request_repository(connection: Connection) -> AccessRequestRepository:
return AccessRequestRepository(connection)


def get_audit_repository(connection: Connection) -> AuditRepository:
return AuditRepository(connection)


Users = Annotated[UserRepository, Depends(get_user_repository)]
Roles = Annotated[RoleRepository, Depends(get_role_repository)]
Invitations = Annotated[InvitationRepository, Depends(get_invitation_repository)]
AccessRequests = Annotated[AccessRequestRepository, Depends(get_access_request_repository)]
Audit = Annotated[AuditRepository, Depends(get_audit_repository)]


def get_access_service(
users: Users,
invitations: Invitations,
access_requests: AccessRequests,
roles: Roles,
audit: Audit,
) -> AccessService:
return AccessService(
users=users,
invitations=invitations,
access_requests=access_requests,
roles=roles,
audit=audit,
)


def get_member_service(users: Users, roles: Roles, audit: Audit) -> MemberService:
return MemberService(users=users, roles=roles, audit=audit)


def get_invitation_service(
invitations: Invitations, roles: Roles, users: Users, audit: Audit, settings: AppSettings
) -> InvitationService:
return InvitationService(
invitations=invitations,
roles=roles,
users=users,
audit=audit,
default_ttl_hours=settings.invitation_ttl_hours,
)


def get_access_request_service(
access_requests: AccessRequests, users: Users, roles: Roles, audit: Audit
) -> AccessRequestService:
return AccessRequestService(
access_requests=access_requests, users=users, roles=roles, audit=audit
)


AccessServiceDep = Annotated[AccessService, Depends(get_access_service)]
MemberServiceDep = Annotated[MemberService, Depends(get_member_service)]
InvitationServiceDep = Annotated[InvitationService, Depends(get_invitation_service)]
AccessRequestServiceDep = Annotated[AccessRequestService, Depends(get_access_request_service)]


async def get_identity(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
verifier: Annotated[TokenVerifier, Depends(get_token_verifier)],
Expand All @@ -48,9 +150,53 @@ async def get_identity(

CurrentIdentity = Annotated[Identity, Depends(get_identity)]

# Add repository and service factories here as the app grows, e.g.
#
# def get_thing_repository(connection: Connection) -> ThingRepository:
# return ThingRepository(connection)
#
# Things = Annotated[ThingRepository, Depends(get_thing_repository)]

async def get_resolved_access(
identity: CurrentIdentity, access: AccessServiceDep
) -> ResolvedAccess:
return await access.resolve(identity)


ResolvedAccessDep = Annotated[ResolvedAccess, Depends(get_resolved_access)]


def get_session(resolved: ResolvedAccessDep) -> Session:
return resolved.session


CurrentSession = Annotated[Session, Depends(get_session)]


def get_auth_context(resolved: ResolvedAccessDep) -> AuthContext:
session = resolved.session

if session.access_state is AccessState.SUSPENDED:
raise AccountSuspendedError("your account has been suspended")

if session.access_state not in PROVISIONED_STATES or session.user is None:
raise AccountNotProvisionedError(
"your account does not have access to this workspace",
details={"access_state": session.access_state.value},
)

return AuthContext(
identity=session.identity,
user=session.user,
permissions=resolved.permissions,
)


CurrentUser = Annotated[AuthContext, Depends(get_auth_context)]


def require(*permissions: Permission) -> Callable[..., AuthContext]:
def dependency(context: CurrentUser) -> AuthContext:
missing = [item for item in permissions if not context.permissions.allows(item)]
if missing:
raise PermissionDeniedError(
"you do not have permission to perform this action",
details={"missing": [item.value for item in missing]},
)
return context

return dependency
14 changes: 13 additions & 1 deletion backend/src/generate_admin/api/router.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
from fastapi import APIRouter

from generate_admin.api.v1 import health
from generate_admin.api.v1 import (
access_requests,
health,
invitations,
members,
roles,
session,
)

api_router = APIRouter(prefix="/api/v1")
api_router.include_router(session.router)
api_router.include_router(members.router)
api_router.include_router(invitations.router)
api_router.include_router(access_requests.router)
api_router.include_router(roles.router)

root_router = APIRouter()
root_router.include_router(health.router)
Loading
Loading