diff --git a/.env.template b/.env.template index f8a298f..7b02103 100644 --- a/.env.template +++ b/.env.template @@ -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 diff --git a/README.md b/README.md index ebc45d3..63ef1d0 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 | diff --git a/backend/alembic/versions/0003_identity_and_rbac.py b/backend/alembic/versions/0003_identity_and_rbac.py new file mode 100644 index 0000000..83f14bc --- /dev/null +++ b/backend/alembic/versions/0003_identity_and_rbac.py @@ -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) diff --git a/backend/src/generate_admin/api/dependencies.py b/backend/src/generate_admin/api/dependencies.py index 17bc406..a54d57e 100644 --- a/backend/src/generate_admin/api/dependencies.py +++ b/backend/src/generate_admin/api/dependencies.py @@ -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 @@ -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 @@ -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)], @@ -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 diff --git a/backend/src/generate_admin/api/router.py b/backend/src/generate_admin/api/router.py index b0570f5..1e5bf1e 100644 --- a/backend/src/generate_admin/api/router.py +++ b/backend/src/generate_admin/api/router.py @@ -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) diff --git a/backend/src/generate_admin/api/v1/access_requests.py b/backend/src/generate_admin/api/v1/access_requests.py new file mode 100644 index 0000000..596d76b --- /dev/null +++ b/backend/src/generate_admin/api/v1/access_requests.py @@ -0,0 +1,50 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends + +from generate_admin.api.dependencies import AccessRequestServiceDep, AuthContext, require +from generate_admin.domain.permissions import Permission +from generate_admin.schemas.access_request import ( + AccessRequestApproval, + AccessRequestDenial, + AccessRequestRead, +) + +router = APIRouter(prefix="/access-requests", tags=["access requests"]) + + +@router.get("", response_model=list[AccessRequestRead]) +async def list_pending_requests( + service: AccessRequestServiceDep, + _: Annotated[AuthContext, Depends(require(Permission.ACCESS_REQUESTS_READ))], +) -> list[AccessRequestRead]: + return await service.list_pending() + + +@router.post("/{request_id}/approve", response_model=AccessRequestRead) +async def approve_request( + request_id: uuid.UUID, + payload: AccessRequestApproval, + service: AccessRequestServiceDep, + context: Annotated[ + AuthContext, + Depends(require(Permission.ACCESS_REQUESTS_REVIEW, Permission.ROLES_GRANT)), + ], +) -> AccessRequestRead: + return await service.approve( + actor=context.user, + actor_permissions=context.permissions, + request_id=request_id, + payload=payload, + ) + + +@router.post("/{request_id}/deny", response_model=AccessRequestRead) +async def deny_request( + request_id: uuid.UUID, + payload: AccessRequestDenial, + service: AccessRequestServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.ACCESS_REQUESTS_REVIEW))], +) -> AccessRequestRead: + return await service.deny(actor=context.user, request_id=request_id, payload=payload) diff --git a/backend/src/generate_admin/api/v1/invitations.py b/backend/src/generate_admin/api/v1/invitations.py new file mode 100644 index 0000000..b48b7eb --- /dev/null +++ b/backend/src/generate_admin/api/v1/invitations.py @@ -0,0 +1,38 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from generate_admin.api.dependencies import AuthContext, InvitationServiceDep, require +from generate_admin.domain.permissions import Permission +from generate_admin.schemas.invitation import InvitationCreate, InvitationCreated, InvitationRead + +router = APIRouter(prefix="/invitations", tags=["invitations"]) + + +@router.get("", response_model=list[InvitationRead]) +async def list_invitations( + service: InvitationServiceDep, + _: Annotated[AuthContext, Depends(require(Permission.MEMBERS_INVITE))], +) -> list[InvitationRead]: + return await service.list_open() + + +@router.post("", response_model=InvitationCreated, status_code=status.HTTP_201_CREATED) +async def create_invitation( + payload: InvitationCreate, + service: InvitationServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.MEMBERS_INVITE))], +) -> InvitationCreated: + return await service.create( + actor=context.user, actor_permissions=context.permissions, payload=payload + ) + + +@router.delete("/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_invitation( + invitation_id: uuid.UUID, + service: InvitationServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.MEMBERS_INVITE))], +) -> None: + await service.revoke(actor=context.user, invitation_id=invitation_id) diff --git a/backend/src/generate_admin/api/v1/members.py b/backend/src/generate_admin/api/v1/members.py new file mode 100644 index 0000000..dc2c6bb --- /dev/null +++ b/backend/src/generate_admin/api/v1/members.py @@ -0,0 +1,44 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends + +from generate_admin.api.dependencies import AuthContext, MemberServiceDep, require +from generate_admin.domain.permissions import Permission +from generate_admin.schemas.role import RoleGrantRequest +from generate_admin.schemas.user import UserRead + +router = APIRouter(prefix="/members", tags=["members"]) + + +@router.get("/{user_id}", response_model=UserRead) +async def get_member( + user_id: uuid.UUID, + service: MemberServiceDep, + _: Annotated[AuthContext, Depends(require(Permission.MEMBERS_READ))], +) -> UserRead: + return await service.get_member(user_id) + + +@router.post("/{user_id}/roles", response_model=UserRead) +async def grant_role( + user_id: uuid.UUID, + payload: RoleGrantRequest, + service: MemberServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.ROLES_GRANT))], +) -> UserRead: + return await service.grant_role( + actor=context.user, + actor_permissions=context.permissions, + user_id=user_id, + payload=payload, + ) + + +@router.delete("/roles/{assignment_id}", response_model=UserRead) +async def revoke_role( + assignment_id: uuid.UUID, + service: MemberServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.ROLES_REVOKE))], +) -> UserRead: + return await service.revoke_role(actor=context.user, assignment_id=assignment_id) diff --git a/backend/src/generate_admin/api/v1/roles.py b/backend/src/generate_admin/api/v1/roles.py new file mode 100644 index 0000000..e2c32be --- /dev/null +++ b/backend/src/generate_admin/api/v1/roles.py @@ -0,0 +1,25 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from generate_admin.api.dependencies import AuthContext, Roles, require +from generate_admin.domain.permissions import Permission +from generate_admin.schemas.role import PermissionRead, RoleRead + +router = APIRouter(tags=["roles"]) + + +@router.get("/roles", response_model=list[RoleRead]) +async def list_roles( + roles: Roles, + _: Annotated[AuthContext, Depends(require(Permission.ROLES_READ))], +) -> list[RoleRead]: + return await roles.list_roles() + + +@router.get("/permissions", response_model=list[PermissionRead]) +async def list_permissions( + roles: Roles, + _: Annotated[AuthContext, Depends(require(Permission.ROLES_READ))], +) -> list[PermissionRead]: + return await roles.list_permissions() diff --git a/backend/src/generate_admin/api/v1/session.py b/backend/src/generate_admin/api/v1/session.py new file mode 100644 index 0000000..69638df --- /dev/null +++ b/backend/src/generate_admin/api/v1/session.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter, status + +from generate_admin.api.dependencies import ( + AccessRequestServiceDep, + CurrentIdentity, + CurrentSession, +) +from generate_admin.schemas.access_request import AccessRequestCreate, AccessRequestRead +from generate_admin.schemas.session import Session + +router = APIRouter(tags=["session"]) + + +@router.get("/session", response_model=Session) +async def read_session(session: CurrentSession) -> Session: + return session + + +@router.post( + "/session/access-request", + response_model=AccessRequestRead, + status_code=status.HTTP_201_CREATED, +) +async def request_access( + identity: CurrentIdentity, + payload: AccessRequestCreate, + service: AccessRequestServiceDep, +) -> AccessRequestRead: + return await service.create(identity=identity, payload=payload) diff --git a/backend/src/generate_admin/cli.py b/backend/src/generate_admin/cli.py new file mode 100644 index 0000000..d96b2fc --- /dev/null +++ b/backend/src/generate_admin/cli.py @@ -0,0 +1,136 @@ +import asyncio +import sys +from datetime import UTC, datetime, timedelta + +import asyncpg + +from generate_admin.core.config import Settings, get_settings +from generate_admin.core.database import create_pool +from generate_admin.domain.permissions import ( + PERMISSION_DESCRIPTIONS, + ROLE_DEFINITIONS, + Permission, + SystemRole, +) +from generate_admin.services.invitation import generate_token + + +async def sync_permissions(connection: asyncpg.Connection) -> None: + await connection.executemany( + """ + INSERT INTO permissions (key, description) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description + """, + [(permission.value, PERMISSION_DESCRIPTIONS[permission]) for permission in Permission], + ) + await connection.execute( + "DELETE FROM permissions WHERE key <> ALL($1::varchar[])", + [permission.value for permission in Permission], + ) + + +async def sync_roles(connection: asyncpg.Connection) -> None: + for definition in ROLE_DEFINITIONS: + role_id = await connection.fetchval( + """ + INSERT INTO roles (key, name, is_system) + VALUES ($1, $2, TRUE) + ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, is_system = TRUE + RETURNING id + """, + definition.key.value, + definition.name, + ) + + keys = [permission.value for permission in definition.permissions] + await connection.executemany( + """ + INSERT INTO role_permissions (role_id, permission_key) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + """, + [(role_id, key) for key in keys], + ) + await connection.execute( + """ + DELETE FROM role_permissions + WHERE role_id = $1 AND permission_key <> ALL($2::varchar[]) + """, + role_id, + keys, + ) + + +async def ensure_owner_invitation(connection: asyncpg.Connection, settings: Settings) -> None: + email = settings.initial_owner_email.strip().lower() + if not email: + return + + existing_owners = await connection.fetchval( + """ + SELECT count(*) + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE r.key = $1 + """, + SystemRole.OWNER.value, + ) + if existing_owners: + return + + open_invitation = await connection.fetchval( + """ + SELECT count(*) FROM invitations + WHERE email = $1 AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now() + """, + email, + ) + if open_invitation: + return + + role_id = await connection.fetchval( + "SELECT id FROM roles WHERE key = $1", SystemRole.OWNER.value + ) + token, token_hash = generate_token() + await connection.execute( + """ + INSERT INTO invitations (email, role_id, token_hash, expires_at) + VALUES ($1, $2, $3, $4) + """, + email, + role_id, + token_hash, + datetime.now(UTC) + timedelta(hours=settings.invitation_ttl_hours), + ) + print(f"owner invitation created for {email}") + print(f"invitation token: {token}") + + +async def seed() -> None: + settings = get_settings() + pool = await create_pool(settings.database) + try: + async with pool.acquire() as connection, connection.transaction(): + await sync_permissions(connection) + await sync_roles(connection) + await ensure_owner_invitation(connection, settings) + print("seed complete") + finally: + await pool.close() + + +COMMANDS = {"seed": seed} + + +def main() -> None: + command = sys.argv[1] if len(sys.argv) > 1 else "" + handler = COMMANDS.get(command) + if handler is None: + print(f"usage: python -m generate_admin.cli [{'|'.join(COMMANDS)}]") + raise SystemExit(1) + asyncio.run(handler()) + + +if __name__ == "__main__": + main() diff --git a/backend/src/generate_admin/core/config.py b/backend/src/generate_admin/core/config.py index cfdb5e1..b2e2a80 100644 --- a/backend/src/generate_admin/core/config.py +++ b/backend/src/generate_admin/core/config.py @@ -109,6 +109,8 @@ class Settings(BaseConfig): cors_allowed_origins_raw: str = Field(default="", alias="CORS_ALLOWED_ORIGINS") redis_url: str = Field(default="", alias="REDIS_URL") + initial_owner_email: str = "" + invitation_ttl_hours: int = 336 @property def cors_allowed_origins(self) -> list[str]: diff --git a/backend/src/generate_admin/domain/access.py b/backend/src/generate_admin/domain/access.py new file mode 100644 index 0000000..b2496c0 --- /dev/null +++ b/backend/src/generate_admin/domain/access.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass + +from generate_admin.domain.permissions import Permission +from generate_admin.schemas.session import Session + + +@dataclass(frozen=True, slots=True) +class PermissionSet: + granted: frozenset[str] + + @classmethod + def from_keys(cls, keys: list[str]) -> "PermissionSet": + return cls(granted=frozenset(keys)) + + def allows(self, permission: Permission) -> bool: + return permission.value in self.granted + + def allows_all(self, permissions: set[Permission]) -> bool: + return all(self.allows(permission) for permission in permissions) + + @property + def keys(self) -> list[str]: + return sorted(self.granted) + + @property + def is_empty(self) -> bool: + return not self.granted + + +@dataclass(frozen=True, slots=True) +class ResolvedAccess: + session: Session + permissions: PermissionSet diff --git a/backend/src/generate_admin/domain/enums.py b/backend/src/generate_admin/domain/enums.py index 7df632e..9227699 100644 --- a/backend/src/generate_admin/domain/enums.py +++ b/backend/src/generate_admin/domain/enums.py @@ -1,6 +1,33 @@ from enum import StrEnum +class UserStatus(StrEnum): + ACTIVE = "active" + SUSPENDED = "suspended" + + +class AccessState(StrEnum): + NO_ACCESS = "no_access" + PENDING = "pending" + DENIED = "denied" + ACTIVE = "active" + NO_ROLES = "no_roles" + SUSPENDED = "suspended" + + +class InvitationStatus(StrEnum): + PENDING = "pending" + ACCEPTED = "accepted" + REVOKED = "revoked" + EXPIRED = "expired" + + +class AccessRequestStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + + class AuditAction(StrEnum): USER_PROVISIONED = "user.provisioned" USER_SUSPENDED = "user.suspended" diff --git a/backend/src/generate_admin/domain/permissions.py b/backend/src/generate_admin/domain/permissions.py new file mode 100644 index 0000000..b964614 --- /dev/null +++ b/backend/src/generate_admin/domain/permissions.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +from enum import StrEnum + + +class Permission(StrEnum): + MEMBERS_READ = "core.members.read" + MEMBERS_INVITE = "core.members.invite" + MEMBERS_SUSPEND = "core.members.suspend" + ROLES_READ = "core.roles.read" + ROLES_GRANT = "core.roles.grant" + ROLES_REVOKE = "core.roles.revoke" + ACCESS_REQUESTS_READ = "core.access_requests.read" + ACCESS_REQUESTS_REVIEW = "core.access_requests.review" + AUDIT_READ = "core.audit.read" + + @property + def description(self) -> str: + return PERMISSION_DESCRIPTIONS[self] + + +PERMISSION_DESCRIPTIONS: dict[Permission, str] = { + Permission.MEMBERS_READ: "View the member directory", + Permission.MEMBERS_INVITE: "Invite people to the workspace", + Permission.MEMBERS_SUSPEND: "Suspend and reinstate member accounts", + Permission.ROLES_READ: "View roles and their permissions", + Permission.ROLES_GRANT: "Grant roles to members", + Permission.ROLES_REVOKE: "Revoke roles from members", + Permission.ACCESS_REQUESTS_READ: "View pending access requests", + Permission.ACCESS_REQUESTS_REVIEW: "Approve or deny access requests", + Permission.AUDIT_READ: "Read the audit log", +} + + +class SystemRole(StrEnum): + OWNER = "owner" + ADMIN = "admin" + + +@dataclass(frozen=True, slots=True) +class RoleDefinition: + key: SystemRole + name: str + permissions: frozenset[Permission] + + +ROLE_DEFINITIONS: tuple[RoleDefinition, ...] = ( + RoleDefinition( + key=SystemRole.OWNER, + name="Owner", + permissions=frozenset(Permission), + ), + RoleDefinition( + key=SystemRole.ADMIN, + name="Admin", + permissions=frozenset(Permission) - {Permission.ROLES_GRANT, Permission.ROLES_REVOKE}, + ), +) + +ROLE_DEFINITIONS_BY_KEY: dict[SystemRole, RoleDefinition] = { + definition.key: definition for definition in ROLE_DEFINITIONS +} diff --git a/backend/src/generate_admin/repositories/__init__.py b/backend/src/generate_admin/repositories/__init__.py index e69de29..a386835 100644 --- a/backend/src/generate_admin/repositories/__init__.py +++ b/backend/src/generate_admin/repositories/__init__.py @@ -0,0 +1,13 @@ +from generate_admin.repositories.access_request import AccessRequestRepository +from generate_admin.repositories.audit import AuditRepository +from generate_admin.repositories.invitation import InvitationRepository +from generate_admin.repositories.role import RoleRepository +from generate_admin.repositories.user import UserRepository + +__all__ = [ + "AccessRequestRepository", + "AuditRepository", + "InvitationRepository", + "RoleRepository", + "UserRepository", +] diff --git a/backend/src/generate_admin/repositories/access_request.py b/backend/src/generate_admin/repositories/access_request.py new file mode 100644 index 0000000..f893e40 --- /dev/null +++ b/backend/src/generate_admin/repositories/access_request.py @@ -0,0 +1,86 @@ +import uuid + +from generate_admin.domain.enums import AccessRequestStatus +from generate_admin.repositories.base import Repository, required_row +from generate_admin.schemas.access_request import AccessRequestRead + +ACCESS_REQUEST_COLUMNS = """ +id, email, entra_object_id, name, message, status, +reviewed_by, reviewed_at, decision_note, created_at +""" + + +class AccessRequestRepository(Repository): + async def create( + self, + *, + email: str, + entra_object_id: uuid.UUID, + name: str, + message: str | None, + ) -> AccessRequestRead: + row = await self.connection.fetchrow( + f""" + INSERT INTO access_requests (email, entra_object_id, name, message) + VALUES ($1, $2, $3, $4) + RETURNING {ACCESS_REQUEST_COLUMNS} + """, + email.lower(), + entra_object_id, + name, + message, + ) + return AccessRequestRead.from_row(required_row(row)) + + async def get_by_id(self, request_id: uuid.UUID) -> AccessRequestRead | None: + row = await self.connection.fetchrow( + f"SELECT {ACCESS_REQUEST_COLUMNS} FROM access_requests WHERE id = $1", request_id + ) + return AccessRequestRead.from_optional_row(row) + + async def find_latest_for_email(self, email: str) -> AccessRequestRead | None: + row = await self.connection.fetchrow( + f""" + SELECT {ACCESS_REQUEST_COLUMNS} + FROM access_requests + WHERE email = $1 + ORDER BY created_at DESC + LIMIT 1 + """, + email.lower(), + ) + return AccessRequestRead.from_optional_row(row) + + async def list_by_status(self, status: AccessRequestStatus) -> list[AccessRequestRead]: + rows = await self.connection.fetch( + f""" + SELECT {ACCESS_REQUEST_COLUMNS} + FROM access_requests + WHERE status = $1 + ORDER BY created_at ASC + """, + status.value, + ) + return AccessRequestRead.from_rows(rows) + + async def record_decision( + self, + *, + request_id: uuid.UUID, + status: AccessRequestStatus, + reviewed_by: uuid.UUID, + note: str | None, + ) -> AccessRequestRead | None: + row = await self.connection.fetchrow( + f""" + UPDATE access_requests + SET status = $2, reviewed_by = $3, reviewed_at = now(), decision_note = $4 + WHERE id = $1 AND status = 'pending' + RETURNING {ACCESS_REQUEST_COLUMNS} + """, + request_id, + status.value, + reviewed_by, + note, + ) + return AccessRequestRead.from_optional_row(row) diff --git a/backend/src/generate_admin/repositories/base.py b/backend/src/generate_admin/repositories/base.py index af47fef..ab454dc 100644 --- a/backend/src/generate_admin/repositories/base.py +++ b/backend/src/generate_admin/repositories/base.py @@ -8,6 +8,12 @@ def __init__(self, connection: asyncpg.Connection) -> None: self.connection = connection +def required_row(row: asyncpg.Record | None) -> asyncpg.Record: + if row is None: + raise RuntimeError("statement returned no row") + return row + + class ConditionSet: def __init__(self, *, start_index: int = 1) -> None: self._clauses: list[str] = [] diff --git a/backend/src/generate_admin/repositories/invitation.py b/backend/src/generate_admin/repositories/invitation.py new file mode 100644 index 0000000..63f17e2 --- /dev/null +++ b/backend/src/generate_admin/repositories/invitation.py @@ -0,0 +1,97 @@ +import uuid +from datetime import datetime + +from generate_admin.repositories.base import Repository, required_row +from generate_admin.schemas.invitation import InvitationRead + +INVITATION_SELECT = """ +SELECT i.id, + i.email, + i.invited_by, + i.expires_at, + i.accepted_at, + i.revoked_at, + i.created_at, + CASE + WHEN i.accepted_at IS NOT NULL THEN 'accepted' + WHEN i.revoked_at IS NOT NULL THEN 'revoked' + WHEN i.expires_at <= now() THEN 'expired' + ELSE 'pending' + END AS status, + jsonb_build_object('id', r.id, 'key', r.key, 'name', r.name) AS role +FROM invitations i +JOIN roles r ON r.id = i.role_id +""" + + +class InvitationRepository(Repository): + async def create( + self, + *, + email: str, + role_id: uuid.UUID, + token_hash: str, + invited_by: uuid.UUID | None, + expires_at: datetime, + ) -> InvitationRead: + created = required_row( + await self.connection.fetchrow( + """ + INSERT INTO invitations (email, role_id, token_hash, invited_by, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + """, + email.lower(), + role_id, + token_hash, + invited_by, + expires_at, + ) + ) + invitation = await self.get_by_id(created["id"]) + if invitation is None: + raise RuntimeError("invitation disappeared immediately after insert") + return invitation + + async def get_by_id(self, invitation_id: uuid.UUID) -> InvitationRead | None: + row = await self.connection.fetchrow(f"{INVITATION_SELECT} WHERE i.id = $1", invitation_id) + return InvitationRead.from_optional_row(row) + + async def find_open_for_email(self, email: str) -> InvitationRead | None: + row = await self.connection.fetchrow( + f""" + {INVITATION_SELECT} + WHERE i.email = $1 + AND i.accepted_at IS NULL + AND i.revoked_at IS NULL + AND i.expires_at > now() + """, + email.lower(), + ) + return InvitationRead.from_optional_row(row) + + async def list_open(self) -> list[InvitationRead]: + rows = await self.connection.fetch( + f""" + {INVITATION_SELECT} + WHERE i.accepted_at IS NULL AND i.revoked_at IS NULL + ORDER BY i.created_at DESC + """ + ) + return InvitationRead.from_rows(rows) + + async def mark_accepted(self, invitation_id: uuid.UUID) -> None: + await self.connection.execute( + "UPDATE invitations SET accepted_at = now() WHERE id = $1", invitation_id + ) + + async def revoke(self, invitation_id: uuid.UUID) -> bool: + result = await self.connection.execute( + """ + UPDATE invitations + SET revoked_at = now() + WHERE id = $1 AND accepted_at IS NULL AND revoked_at IS NULL + """, + invitation_id, + ) + return result.endswith("1") diff --git a/backend/src/generate_admin/repositories/role.py b/backend/src/generate_admin/repositories/role.py new file mode 100644 index 0000000..ebf18d3 --- /dev/null +++ b/backend/src/generate_admin/repositories/role.py @@ -0,0 +1,74 @@ +import uuid +from datetime import datetime + +from generate_admin.repositories.base import Repository +from generate_admin.schemas.role import PermissionRead, RoleRead + +ROLE_SELECT = """ +SELECT r.id, + r.key, + r.name, + r.is_system, + COALESCE( + array_agg(rp.permission_key ORDER BY rp.permission_key) + FILTER (WHERE rp.permission_key IS NOT NULL), + ARRAY[]::varchar[] + ) AS permissions +FROM roles r +LEFT JOIN role_permissions rp ON rp.role_id = r.id +""" + + +class RoleRepository(Repository): + async def list_roles(self) -> list[RoleRead]: + rows = await self.connection.fetch(f"{ROLE_SELECT} GROUP BY r.id ORDER BY r.name") + return RoleRead.from_rows(rows) + + async def get_by_id(self, role_id: uuid.UUID) -> RoleRead | None: + row = await self.connection.fetchrow( + f"{ROLE_SELECT} WHERE r.id = $1 GROUP BY r.id", role_id + ) + return RoleRead.from_optional_row(row) + + async def get_by_key(self, key: str) -> RoleRead | None: + row = await self.connection.fetchrow(f"{ROLE_SELECT} WHERE r.key = $1 GROUP BY r.id", key) + return RoleRead.from_optional_row(row) + + async def list_permissions(self) -> list[PermissionRead]: + rows = await self.connection.fetch("SELECT key, description FROM permissions ORDER BY key") + return PermissionRead.from_rows(rows) + + async def grant( + self, + *, + user_id: uuid.UUID, + role_id: uuid.UUID, + granted_by: uuid.UUID | None, + expires_at: datetime | None, + ) -> uuid.UUID: + return await self.connection.fetchval( + """ + INSERT INTO user_roles (user_id, role_id, granted_by, expires_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, role_id) + DO UPDATE SET expires_at = EXCLUDED.expires_at, + granted_by = EXCLUDED.granted_by, + granted_at = now() + RETURNING id + """, + user_id, + role_id, + granted_by, + expires_at, + ) + + async def revoke(self, assignment_id: uuid.UUID) -> bool: + result = await self.connection.execute( + "DELETE FROM user_roles WHERE id = $1", assignment_id + ) + return result.endswith("1") + + async def get_assignment_owner(self, assignment_id: uuid.UUID) -> uuid.UUID | None: + return await self.connection.fetchval( + "SELECT user_id FROM user_roles WHERE id = $1", assignment_id + ) diff --git a/backend/src/generate_admin/repositories/user.py b/backend/src/generate_admin/repositories/user.py new file mode 100644 index 0000000..d723bcc --- /dev/null +++ b/backend/src/generate_admin/repositories/user.py @@ -0,0 +1,115 @@ +import uuid + +from generate_admin.domain.enums import UserStatus +from generate_admin.repositories.base import Repository, required_row +from generate_admin.schemas.role import RoleAssignmentRead +from generate_admin.schemas.user import UserRead + +USER_SELECT = """ +SELECT u.id, + u.entra_object_id, + u.email, + u.name, + u.status, + u.last_login_at, + u.created_at +FROM users u +""" + + +class UserRepository(Repository): + async def get_by_id(self, user_id: uuid.UUID) -> UserRead | None: + row = await self.connection.fetchrow(f"{USER_SELECT} WHERE u.id = $1", user_id) + return UserRead.from_optional_row(row) + + async def get_by_entra_object_id(self, entra_object_id: uuid.UUID) -> UserRead | None: + row = await self.connection.fetchrow( + f"{USER_SELECT} WHERE u.entra_object_id = $1", entra_object_id + ) + return UserRead.from_optional_row(row) + + async def get_by_email(self, email: str) -> UserRead | None: + row = await self.connection.fetchrow(f"{USER_SELECT} WHERE u.email = $1", email.lower()) + return UserRead.from_optional_row(row) + + async def create(self, *, entra_object_id: uuid.UUID, email: str, name: str) -> UserRead: + created = required_row( + await self.connection.fetchrow( + """ + INSERT INTO users (entra_object_id, email, name, last_login_at) + VALUES ($1, $2, $3, now()) + RETURNING id + """, + entra_object_id, + email.lower(), + name, + ) + ) + user = await self.get_by_id(created["id"]) + if user is None: + raise RuntimeError("user disappeared immediately after insert") + return user + + async def set_status(self, user_id: uuid.UUID, status: UserStatus) -> UserRead | None: + await self.connection.execute( + "UPDATE users SET status = $2 WHERE id = $1", user_id, status.value + ) + return await self.get_by_id(user_id) + + async def touch_last_login(self, user_id: uuid.UUID) -> None: + await self.connection.execute( + """ + UPDATE users + SET last_login_at = now() + WHERE id = $1 + AND (last_login_at IS NULL OR last_login_at < now() - interval '1 hour') + """, + user_id, + ) + + async def list_role_assignments(self, user_id: uuid.UUID) -> list[RoleAssignmentRead]: + rows = await self.connection.fetch( + """ + SELECT ur.id, + ur.granted_at, + ur.expires_at, + jsonb_build_object('id', r.id, 'key', r.key, 'name', r.name) AS role + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = $1 + AND (ur.expires_at IS NULL OR ur.expires_at > now()) + ORDER BY r.name + """, + user_id, + ) + return RoleAssignmentRead.from_rows(rows) + + async def list_granted_permissions(self, user_id: uuid.UUID) -> list[str]: + rows = await self.connection.fetch( + """ + SELECT DISTINCT rp.permission_key + FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + WHERE ur.user_id = $1 + AND (ur.expires_at IS NULL OR ur.expires_at > now()) + """, + user_id, + ) + return [row["permission_key"] for row in rows] + + async def count_active_holders_of_role(self, role_key: str) -> int: + return ( + await self.connection.fetchval( + """ + SELECT count(DISTINCT ur.user_id) + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + JOIN users u ON u.id = ur.user_id + WHERE r.key = $1 + AND u.status = 'active' + AND (ur.expires_at IS NULL OR ur.expires_at > now()) + """, + role_key, + ) + or 0 + ) diff --git a/backend/src/generate_admin/schemas/access_request.py b/backend/src/generate_admin/schemas/access_request.py new file mode 100644 index 0000000..6c95d2a --- /dev/null +++ b/backend/src/generate_admin/schemas/access_request.py @@ -0,0 +1,34 @@ +import uuid +from datetime import datetime + +from pydantic import EmailStr, Field + +from generate_admin.domain.enums import AccessRequestStatus +from generate_admin.schemas.base import ReadDTO, RequestDTO + + +class AccessRequestCreate(RequestDTO): + message: str | None = Field(default=None, max_length=1000) + + +class AccessRequestApproval(RequestDTO): + role_id: uuid.UUID + expires_at: datetime | None = None + note: str | None = Field(default=None, max_length=1000) + + +class AccessRequestDenial(RequestDTO): + note: str | None = Field(default=None, max_length=1000) + + +class AccessRequestRead(ReadDTO): + id: uuid.UUID + email: EmailStr + entra_object_id: uuid.UUID + name: str + message: str | None + status: AccessRequestStatus + reviewed_by: uuid.UUID | None + reviewed_at: datetime | None + decision_note: str | None + created_at: datetime diff --git a/backend/src/generate_admin/schemas/invitation.py b/backend/src/generate_admin/schemas/invitation.py new file mode 100644 index 0000000..b17b6dd --- /dev/null +++ b/backend/src/generate_admin/schemas/invitation.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime + +from pydantic import EmailStr, Field + +from generate_admin.domain.enums import InvitationStatus +from generate_admin.schemas.base import ReadDTO, RequestDTO +from generate_admin.schemas.role import RoleSummary + + +class InvitationCreate(RequestDTO): + email: EmailStr + role_id: uuid.UUID + expires_in_hours: int | None = Field(default=None, ge=1, le=8760) + + +class InvitationRead(ReadDTO): + id: uuid.UUID + email: EmailStr + role: RoleSummary + status: InvitationStatus + invited_by: uuid.UUID | None + expires_at: datetime + accepted_at: datetime | None + revoked_at: datetime | None + created_at: datetime + + +class InvitationCreated(ReadDTO): + invitation: InvitationRead + token: str diff --git a/backend/src/generate_admin/schemas/role.py b/backend/src/generate_admin/schemas/role.py new file mode 100644 index 0000000..dbb1534 --- /dev/null +++ b/backend/src/generate_admin/schemas/role.py @@ -0,0 +1,34 @@ +import uuid +from datetime import datetime + +from pydantic import Field + +from generate_admin.schemas.base import ReadDTO, RequestDTO + + +class PermissionRead(ReadDTO): + key: str + description: str + + +class RoleSummary(ReadDTO): + id: uuid.UUID + key: str + name: str + + +class RoleRead(RoleSummary): + is_system: bool + permissions: list[str] = Field(default_factory=list) + + +class RoleAssignmentRead(ReadDTO): + id: uuid.UUID + role: RoleSummary + granted_at: datetime + expires_at: datetime | None + + +class RoleGrantRequest(RequestDTO): + role_id: uuid.UUID + expires_at: datetime | None = None diff --git a/backend/src/generate_admin/schemas/session.py b/backend/src/generate_admin/schemas/session.py index d7c8c94..9efad38 100644 --- a/backend/src/generate_admin/schemas/session.py +++ b/backend/src/generate_admin/schemas/session.py @@ -1,13 +1,24 @@ import uuid -from pydantic import EmailStr +from pydantic import EmailStr, Field +from generate_admin.domain.enums import AccessState from generate_admin.schemas.base import ReadDTO +from generate_admin.schemas.user import UserRead class Identity(ReadDTO): - """Who the caller is, straight from a verified Entra token.""" - entra_object_id: uuid.UUID email: EmailStr name: str + + +class Session(ReadDTO): + access_state: AccessState + identity: Identity + user: UserRead | None = None + permissions: list[str] = Field(default_factory=list) + + @property + def is_active(self) -> bool: + return self.access_state is AccessState.ACTIVE diff --git a/backend/src/generate_admin/schemas/user.py b/backend/src/generate_admin/schemas/user.py new file mode 100644 index 0000000..390163a --- /dev/null +++ b/backend/src/generate_admin/schemas/user.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from pydantic import EmailStr, Field + +from generate_admin.domain.enums import UserStatus +from generate_admin.schemas.base import ReadDTO +from generate_admin.schemas.role import RoleAssignmentRead + + +class UserSummary(ReadDTO): + id: uuid.UUID + email: EmailStr + name: str + status: UserStatus + + +class UserRead(UserSummary): + entra_object_id: uuid.UUID + last_login_at: datetime | None + created_at: datetime + role_assignments: list[RoleAssignmentRead] = Field(default_factory=list) diff --git a/backend/src/generate_admin/services/__init__.py b/backend/src/generate_admin/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/generate_admin/services/access.py b/backend/src/generate_admin/services/access.py new file mode 100644 index 0000000..6a63323 --- /dev/null +++ b/backend/src/generate_admin/services/access.py @@ -0,0 +1,122 @@ +from generate_admin.domain.access import PermissionSet, ResolvedAccess +from generate_admin.domain.enums import ( + AccessRequestStatus, + AccessState, + AuditAction, + UserStatus, +) +from generate_admin.repositories.access_request import AccessRequestRepository +from generate_admin.repositories.audit import AuditRepository +from generate_admin.repositories.invitation import InvitationRepository +from generate_admin.repositories.role import RoleRepository +from generate_admin.repositories.user import UserRepository +from generate_admin.schemas.audit import AuditEntry +from generate_admin.schemas.invitation import InvitationRead +from generate_admin.schemas.session import Identity, Session +from generate_admin.schemas.user import UserRead + + +class AccessService: + def __init__( + self, + *, + users: UserRepository, + invitations: InvitationRepository, + access_requests: AccessRequestRepository, + roles: RoleRepository, + audit: AuditRepository, + ) -> None: + self._users = users + self._invitations = invitations + self._access_requests = access_requests + self._roles = roles + self._audit = audit + + async def resolve(self, identity: Identity) -> ResolvedAccess: + user = await self._users.get_by_entra_object_id(identity.entra_object_id) + + if user is None: + invitation = await self._invitations.find_open_for_email(identity.email) + if invitation is not None: + user = await self._accept_invitation(identity, invitation) + + if user is None: + return ResolvedAccess( + session=Session( + access_state=await self._state_without_account(identity), + identity=identity, + ), + permissions=PermissionSet.from_keys([]), + ) + + return await self._session_for_user(identity, user) + + async def _state_without_account(self, identity: Identity) -> AccessState: + request = await self._access_requests.find_latest_for_email(identity.email) + if request is None: + return AccessState.NO_ACCESS + if request.status is AccessRequestStatus.PENDING: + return AccessState.PENDING + if request.status is AccessRequestStatus.DENIED: + return AccessState.DENIED + return AccessState.NO_ACCESS + + async def _session_for_user(self, identity: Identity, user: UserRead) -> ResolvedAccess: + if user.status is UserStatus.SUSPENDED: + return ResolvedAccess( + session=Session(access_state=AccessState.SUSPENDED, identity=identity, user=user), + permissions=PermissionSet.from_keys([]), + ) + + await self._users.touch_last_login(user.id) + + permissions = PermissionSet.from_keys(await self._users.list_granted_permissions(user.id)) + assignments = await self._users.list_role_assignments(user.id) + user = user.model_copy(update={"role_assignments": assignments}) + + return ResolvedAccess( + session=Session( + access_state=AccessState.NO_ROLES if permissions.is_empty else AccessState.ACTIVE, + identity=identity, + user=user, + permissions=permissions.keys, + ), + permissions=permissions, + ) + + async def _accept_invitation(self, identity: Identity, invitation: InvitationRead) -> UserRead: + user = await self._users.create( + entra_object_id=identity.entra_object_id, + email=identity.email, + name=identity.name, + ) + + await self._roles.grant( + user_id=user.id, + role_id=invitation.role.id, + granted_by=invitation.invited_by, + expires_at=None, + ) + await self._invitations.mark_accepted(invitation.id) + + await self._audit.record( + AuditEntry( + actor_id=user.id, + actor_email=user.email, + action=AuditAction.INVITATION_ACCEPTED, + resource_type="invitation", + resource_id=str(invitation.id), + after={"user_id": str(user.id), "role_key": invitation.role.key}, + ) + ) + await self._audit.record( + AuditEntry( + actor_id=user.id, + actor_email=user.email, + action=AuditAction.USER_PROVISIONED, + resource_type="user", + resource_id=str(user.id), + after={"email": user.email, "source": "invitation"}, + ) + ) + return user diff --git a/backend/src/generate_admin/services/access_request.py b/backend/src/generate_admin/services/access_request.py new file mode 100644 index 0000000..a2aeb75 --- /dev/null +++ b/backend/src/generate_admin/services/access_request.py @@ -0,0 +1,145 @@ +import uuid + +from generate_admin.core.errors import ConflictError, NotFoundError, ValidationError +from generate_admin.domain.access import PermissionSet +from generate_admin.domain.enums import AccessRequestStatus, AuditAction +from generate_admin.repositories.access_request import AccessRequestRepository +from generate_admin.repositories.audit import AuditRepository +from generate_admin.repositories.role import RoleRepository +from generate_admin.repositories.user import UserRepository +from generate_admin.schemas.access_request import ( + AccessRequestApproval, + AccessRequestCreate, + AccessRequestDenial, + AccessRequestRead, +) +from generate_admin.schemas.audit import AuditEntry +from generate_admin.schemas.session import Identity +from generate_admin.schemas.user import UserRead +from generate_admin.services.guards import ensure_can_delegate + + +class AccessRequestService: + def __init__( + self, + *, + access_requests: AccessRequestRepository, + users: UserRepository, + roles: RoleRepository, + audit: AuditRepository, + ) -> None: + self._access_requests = access_requests + self._users = users + self._roles = roles + self._audit = audit + + async def create( + self, *, identity: Identity, payload: AccessRequestCreate + ) -> AccessRequestRead: + if await self._users.get_by_entra_object_id(identity.entra_object_id) is not None: + raise ConflictError("you already have an account") + + existing = await self._access_requests.find_latest_for_email(identity.email) + if existing is not None and existing.status is AccessRequestStatus.PENDING: + return existing + + request = await self._access_requests.create( + email=identity.email, + entra_object_id=identity.entra_object_id, + name=identity.name, + message=payload.message, + ) + + await self._audit.record( + AuditEntry( + actor_email=identity.email, + action=AuditAction.ACCESS_REQUEST_CREATED, + resource_type="access_request", + resource_id=str(request.id), + after={"email": identity.email}, + ) + ) + return request + + async def list_pending(self) -> list[AccessRequestRead]: + return await self._access_requests.list_by_status(AccessRequestStatus.PENDING) + + async def approve( + self, + *, + actor: UserRead, + actor_permissions: PermissionSet, + request_id: uuid.UUID, + payload: AccessRequestApproval, + ) -> AccessRequestRead: + request = await self._access_requests.get_by_id(request_id) + if request is None: + raise NotFoundError("access request does not exist") + if request.status is not AccessRequestStatus.PENDING: + raise ValidationError("access request has already been reviewed") + + role = await self._roles.get_by_id(payload.role_id) + if role is None: + raise NotFoundError("role does not exist") + + ensure_can_delegate(actor_permissions, role) + + user = await self._users.get_by_entra_object_id(request.entra_object_id) + if user is None: + user = await self._users.create( + entra_object_id=request.entra_object_id, + email=request.email, + name=request.name, + ) + + await self._roles.grant( + user_id=user.id, + role_id=role.id, + granted_by=actor.id, + expires_at=payload.expires_at, + ) + + decided = await self._access_requests.record_decision( + request_id=request_id, + status=AccessRequestStatus.APPROVED, + reviewed_by=actor.id, + note=payload.note, + ) + if decided is None: + raise ValidationError("access request has already been reviewed") + + await self._audit.record( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.ACCESS_REQUEST_APPROVED, + resource_type="access_request", + resource_id=str(request_id), + after={"user_id": str(user.id), "role_key": role.key}, + ) + ) + return decided + + async def deny( + self, *, actor: UserRead, request_id: uuid.UUID, payload: AccessRequestDenial + ) -> AccessRequestRead: + decided = await self._access_requests.record_decision( + request_id=request_id, + status=AccessRequestStatus.DENIED, + reviewed_by=actor.id, + note=payload.note, + ) + if decided is None: + raise NotFoundError("no pending access request with that id") + + await self._audit.record( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.ACCESS_REQUEST_DENIED, + resource_type="access_request", + resource_id=str(request_id), + after={"note": payload.note}, + ) + ) + return decided diff --git a/backend/src/generate_admin/services/guards.py b/backend/src/generate_admin/services/guards.py new file mode 100644 index 0000000..cc9127c --- /dev/null +++ b/backend/src/generate_admin/services/guards.py @@ -0,0 +1,22 @@ +from generate_admin.core.errors import LastOwnerError, PrivilegeEscalationError +from generate_admin.domain.access import PermissionSet +from generate_admin.domain.permissions import Permission, SystemRole +from generate_admin.repositories.user import UserRepository +from generate_admin.schemas.role import RoleRead + + +def ensure_can_delegate(actor: PermissionSet, role: RoleRead) -> None: + delegated = {Permission(key) for key in role.permissions} + missing = {permission for permission in delegated if not actor.allows(permission)} + if missing: + raise PrivilegeEscalationError( + "cannot grant permissions you do not hold", + details={"missing": sorted(permission.value for permission in missing)}, + ) + + +async def ensure_not_last_owner(users: UserRepository, role_key: str) -> None: + if role_key != SystemRole.OWNER.value: + return + if await users.count_active_holders_of_role(SystemRole.OWNER.value) <= 1: + raise LastOwnerError("the workspace must keep at least one active owner") diff --git a/backend/src/generate_admin/services/invitation.py b/backend/src/generate_admin/services/invitation.py new file mode 100644 index 0000000..7fe38b8 --- /dev/null +++ b/backend/src/generate_admin/services/invitation.py @@ -0,0 +1,108 @@ +import hashlib +import secrets +import uuid +from datetime import UTC, datetime, timedelta + +from generate_admin.core.errors import ConflictError, NotFoundError, ValidationError +from generate_admin.domain.access import PermissionSet +from generate_admin.domain.enums import AuditAction +from generate_admin.repositories.audit import AuditRepository +from generate_admin.repositories.invitation import InvitationRepository +from generate_admin.repositories.role import RoleRepository +from generate_admin.repositories.user import UserRepository +from generate_admin.schemas.audit import AuditEntry +from generate_admin.schemas.invitation import InvitationCreate, InvitationCreated, InvitationRead +from generate_admin.schemas.user import UserRead +from generate_admin.services.guards import ensure_can_delegate + +TOKEN_BYTES = 32 + + +def generate_token() -> tuple[str, str]: + token = secrets.token_urlsafe(TOKEN_BYTES) + return token, hashlib.sha256(token.encode()).hexdigest() + + +class InvitationService: + def __init__( + self, + *, + invitations: InvitationRepository, + roles: RoleRepository, + users: UserRepository, + audit: AuditRepository, + default_ttl_hours: int, + ) -> None: + self._invitations = invitations + self._roles = roles + self._users = users + self._audit = audit + self._default_ttl_hours = default_ttl_hours + + async def create( + self, + *, + actor: UserRead, + actor_permissions: PermissionSet, + payload: InvitationCreate, + ) -> InvitationCreated: + email = payload.email.lower() + + if await self._users.get_by_email(email) is not None: + raise ConflictError("that person is already a member") + + if await self._invitations.find_open_for_email(email) is not None: + raise ConflictError("an open invitation already exists for that email") + + role = await self._roles.get_by_id(payload.role_id) + if role is None: + raise NotFoundError("role does not exist") + + ensure_can_delegate(actor_permissions, role) + + token, token_hash = generate_token() + ttl_hours = payload.expires_in_hours or self._default_ttl_hours + expires_at = datetime.now(UTC) + timedelta(hours=ttl_hours) + + invitation = await self._invitations.create( + email=email, + role_id=role.id, + token_hash=token_hash, + invited_by=actor.id, + expires_at=expires_at, + ) + + await self._audit.record( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.INVITATION_CREATED, + resource_type="invitation", + resource_id=str(invitation.id), + after={"email": email, "role_key": role.key}, + ) + ) + + return InvitationCreated(invitation=invitation, token=token) + + async def list_open(self) -> list[InvitationRead]: + return await self._invitations.list_open() + + async def revoke(self, *, actor: UserRead, invitation_id: uuid.UUID) -> None: + invitation = await self._invitations.get_by_id(invitation_id) + if invitation is None: + raise NotFoundError("invitation does not exist") + + if not await self._invitations.revoke(invitation_id): + raise ValidationError("invitation is already accepted or revoked") + + await self._audit.record( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.INVITATION_REVOKED, + resource_type="invitation", + resource_id=str(invitation_id), + before={"email": invitation.email, "status": invitation.status.value}, + ) + ) diff --git a/backend/src/generate_admin/services/member.py b/backend/src/generate_admin/services/member.py new file mode 100644 index 0000000..981efb6 --- /dev/null +++ b/backend/src/generate_admin/services/member.py @@ -0,0 +1,98 @@ +import uuid + +from generate_admin.core.errors import NotFoundError +from generate_admin.domain.access import PermissionSet +from generate_admin.domain.enums import AuditAction +from generate_admin.repositories.audit import AuditRepository +from generate_admin.repositories.role import RoleRepository +from generate_admin.repositories.user import UserRepository +from generate_admin.schemas.audit import AuditEntry +from generate_admin.schemas.role import RoleGrantRequest +from generate_admin.schemas.user import UserRead +from generate_admin.services.guards import ensure_can_delegate, ensure_not_last_owner + + +class MemberService: + def __init__( + self, + *, + users: UserRepository, + roles: RoleRepository, + audit: AuditRepository, + ) -> None: + self._users = users + self._roles = roles + self._audit = audit + + async def get_member(self, user_id: uuid.UUID) -> UserRead: + user = await self._users.get_by_id(user_id) + if user is None: + raise NotFoundError("member does not exist") + assignments = await self._users.list_role_assignments(user_id) + return user.model_copy(update={"role_assignments": assignments}) + + async def grant_role( + self, + *, + actor: UserRead, + actor_permissions: PermissionSet, + user_id: uuid.UUID, + payload: RoleGrantRequest, + ) -> UserRead: + if await self._users.get_by_id(user_id) is None: + raise NotFoundError("member does not exist") + + role = await self._roles.get_by_id(payload.role_id) + if role is None: + raise NotFoundError("role does not exist") + + ensure_can_delegate(actor_permissions, role) + + await self._roles.grant( + user_id=user_id, + role_id=role.id, + granted_by=actor.id, + expires_at=payload.expires_at, + ) + + await self._audit.record( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.ROLE_GRANTED, + resource_type="user", + resource_id=str(user_id), + after={ + "role_key": role.key, + "expires_at": payload.expires_at.isoformat() if payload.expires_at else None, + }, + ) + ) + return await self.get_member(user_id) + + async def revoke_role(self, *, actor: UserRead, assignment_id: uuid.UUID) -> UserRead: + user_id = await self._roles.get_assignment_owner(assignment_id) + if user_id is None: + raise NotFoundError("role assignment does not exist") + + member = await self.get_member(user_id) + assignment = next( + (item for item in member.role_assignments if item.id == assignment_id), None + ) + if assignment is None: + raise NotFoundError("role assignment does not exist") + + await ensure_not_last_owner(self._users, assignment.role.key) + await self._roles.revoke(assignment_id) + + await self._audit.record( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.ROLE_REVOKED, + resource_type="user", + resource_id=str(user_id), + before={"role_key": assignment.role.key}, + ) + ) + return await self.get_member(user_id) diff --git a/backend/tests/test_access.py b/backend/tests/test_access.py new file mode 100644 index 0000000..c564e23 --- /dev/null +++ b/backend/tests/test_access.py @@ -0,0 +1,64 @@ +import base64 +import json +import uuid + +from fastapi.testclient import TestClient + + +def local_token(email: str, object_id: uuid.UUID) -> str: + claims = json.dumps({"oid": str(object_id), "email": email, "name": "Test Person"}) + return base64.urlsafe_b64encode(claims.encode()).decode().rstrip("=") + + +def auth_header(email: str, object_id: uuid.UUID) -> dict[str, str]: + return {"Authorization": f"Bearer {local_token(email, object_id)}"} + + +def test_session_requires_a_token(client: TestClient) -> None: + response = client.get("/api/v1/session") + + assert response.status_code == 401 + + +def test_stranger_gets_a_no_access_session(client: TestClient) -> None: + object_id = uuid.uuid4() + headers = auth_header(f"{object_id}@example.com", object_id) + + response = client.get("/api/v1/session", headers=headers) + + assert response.status_code == 200 + body = response.json() + assert body["access_state"] == "no_access" + assert body["user"] is None + assert body["permissions"] == [] + + +def test_access_request_moves_the_session_to_pending(client: TestClient) -> None: + object_id = uuid.uuid4() + headers = auth_header(f"{object_id}@example.com", object_id) + + created = client.post( + "/api/v1/session/access-request", headers=headers, json={"message": "let me in"} + ) + + assert created.status_code == 201 + assert created.json()["status"] == "pending" + + repeated = client.post("/api/v1/session/access-request", headers=headers, json={}) + + assert repeated.status_code == 201 + assert repeated.json()["id"] == created.json()["id"] + + session = client.get("/api/v1/session", headers=headers) + + assert session.json()["access_state"] == "pending" + + +def test_unprovisioned_caller_cannot_read_roles(client: TestClient) -> None: + object_id = uuid.uuid4() + headers = auth_header(f"{object_id}@example.com", object_id) + + response = client.get("/api/v1/roles", headers=headers) + + assert response.status_code == 403 + assert response.json()["code"] == "account_not_provisioned" diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py new file mode 100644 index 0000000..e95cc64 --- /dev/null +++ b/backend/tests/test_permissions.py @@ -0,0 +1,66 @@ +import uuid + +import pytest + +from generate_admin.core.errors import PrivilegeEscalationError +from generate_admin.domain.access import PermissionSet +from generate_admin.domain.permissions import ( + ROLE_DEFINITIONS_BY_KEY, + Permission, + SystemRole, +) +from generate_admin.schemas.role import RoleRead +from generate_admin.services.guards import ensure_can_delegate + + +def role_for(key: SystemRole) -> RoleRead: + definition = ROLE_DEFINITIONS_BY_KEY[key] + return RoleRead( + id=uuid.uuid4(), + key=definition.key.value, + name=definition.name, + is_system=True, + permissions=sorted(permission.value for permission in definition.permissions), + ) + + +def permissions_for(key: SystemRole) -> PermissionSet: + definition = ROLE_DEFINITIONS_BY_KEY[key] + return PermissionSet.from_keys([permission.value for permission in definition.permissions]) + + +def test_owner_holds_every_permission() -> None: + owner = permissions_for(SystemRole.OWNER) + + assert owner.allows_all(set(Permission)) + assert not owner.is_empty + + +def test_admin_cannot_hand_out_roles() -> None: + admin = permissions_for(SystemRole.ADMIN) + + assert admin.allows(Permission.MEMBERS_INVITE) + assert admin.allows(Permission.ACCESS_REQUESTS_REVIEW) + assert not admin.allows(Permission.ROLES_GRANT) + assert not admin.allows(Permission.ROLES_REVOKE) + + +def test_empty_permission_set_allows_nothing() -> None: + nobody = PermissionSet.from_keys([]) + + assert nobody.is_empty + assert not nobody.allows(Permission.MEMBERS_READ) + + +def test_admin_may_not_delegate_owner() -> None: + with pytest.raises(PrivilegeEscalationError) as error: + ensure_can_delegate(permissions_for(SystemRole.ADMIN), role_for(SystemRole.OWNER)) + + assert error.value.details["missing"] == [ + Permission.ROLES_GRANT.value, + Permission.ROLES_REVOKE.value, + ] + + +def test_owner_may_delegate_admin() -> None: + ensure_can_delegate(permissions_for(SystemRole.OWNER), role_for(SystemRole.ADMIN)) diff --git a/justfile b/justfile index a4e3f3f..386809c 100644 --- a/justfile +++ b/justfile @@ -32,6 +32,9 @@ rollback: revision message: cd {{backend}} && uv run alembic revision -m "{{message}}" +seed: + cd {{backend}} && uv run python -m generate_admin.cli seed + test: cd {{backend}} && uv run pytest