Skip to content

msflib-workspaces

Purpose

msflib-workspaces adds workspaces and workspace membership. A workspace is a team or project space inside a tenant. A membership (the User model, table workspace_user) links one account to one workspace and carries the account's role and status there.

The module supplies the models, actions, routers for managing workspaces and joining or switching between them, and optional event hooks that create a default workspace and membership when an account is created. It builds on msflib-account and msflib-tenancy. Request-time workspace resolution is in msflib-auth.

Install

[tool.poetry.dependencies]
msflib = { git = "https://github.com/msflib/fastapi.git", subdirectory = "core", rev = "core-v0.2.1" }
msflib-account = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/account", rev = "account-v0.2.2" }
msflib-tenancy = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/tenancy", rev = "tenancy-v0.2.0" }
msflib-workspaces = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/workspaces", rev = "workspaces-v0.2.1" }

Import paths:

  • msflib.workspaces loads attributes lazily and exposes only DependencyNamespace and get_workspace_form_dependencies.
  • Schemas and enums come from msflib.workspaces.models. The table classes do not: use msflib.workspaces.models.workspace.Workspace and msflib.workspaces.models.user.User.
  • Actions are in msflib.workspaces.actions, routers in msflib.workspaces.router, hooks in msflib.workspaces.eventbus, settings in msflib.workspaces.config.

Wiring into a host app

The tables reference account.id and tenant.id, so import the Account and Tenant models before creating tables. Seed the default tenant at startup, then mount the routers.

from fastapi import FastAPI
from msflib.account.actions import AccountAction
from msflib.account.models import AccountCreate, AccountUpdate
from msflib.account.models.account import Account
from msflib.eventbus import bind_app_emitter
from msflib.tenancy.deps import get_tenant_dependencies
from msflib.workspaces.actions import UserAction, WorkspaceAction
from msflib.workspaces.eventbus import register_event_hooks
from msflib.workspaces.models import UserCreate, UserUpdate, WorkspaceCreate, WorkspaceUpdate
from msflib.workspaces.models.user import User
from msflib.workspaces.models.workspace import Workspace
from msflib.workspaces.router import router as workspaces_router
from msflib.workspaces.router import user_me_router, user_workspace_router

workspace_action = WorkspaceAction[Workspace, WorkspaceCreate, WorkspaceUpdate](settings=settings)
user_action = UserAction[User, UserCreate, UserUpdate]()
account_action = AccountAction[Account, AccountCreate, AccountUpdate](settings=settings)

app = FastAPI()
app_emitter = bind_app_emitter(app)

if workspace_action.settings.AUTO_REGISTER_EVENT_HOOKS:
    register_event_hooks(
        workspace_action=workspace_action,
        user_action=user_action,
        account_action=account_action,
        emitter=app_emitter,
        tenancy_settings=settings.scope("TENANCY"),
    )

tenant_deps = get_tenant_dependencies(session_dep=get_session, settings=settings.scope("TENANCY"))

app.include_router(
    workspaces_router(
        get_session=get_session,
        get_current_account=account_deps.get_current_account,
        get_current_account_or_none=account_deps.get_current_account_or_none,
        role_check=account_deps.RoleCheck,
        settings=settings,
        account_type=Account,
        workspace_type=Workspace,
        user_type=User,
        get_current_tenant=tenant_deps.get_current_tenant,
        prefix="/workspaces",
    )
)
app.include_router(
    user_workspace_router(
        get_session=get_session,
        get_current_account=account_deps.get_current_account,
        get_current_active_account=account_deps.get_current_active_account,
        settings=settings,
        workspace_type=Workspace,
        user_type=User,
    )
)
app.include_router(
    user_me_router(
        get_session=get_session,
        get_current_account=account_deps.get_current_account,
        get_current_active_account=account_deps.get_current_active_account,
        get_current_workspace=user_deps.get_current_workspace,
        user_type=User,
    )
)

account_deps and user_deps are the namespaces from auth.

Known issue (#288)

Without settings= on get_tenant_dependencies (and where the workspaces router falls back to the default tenant), a host that sets DEFAULT_TENANT_SLUG seeds one slug while requests look for default, and POST /workspaces/ returns 500 "Default tenant has not been seeded". Pass settings=settings.scope("TENANCY") as shown above, and keep the slug default if you rely on a router's own fallback.

Warning

router(...) defaults workspace_type and user_type to the abstract WorkspaceBase and UserBase, which are not tables. Always pass your concrete table classes.

The default settings in your host must include WorkspaceSettings and TenancySettings (and the account and auth settings the other modules use), for example class Settings(WorkspaceSettings, TenancySettings, AccountSettings, AuthSettings, CoreSettings, SettingsBase).

Routes

Router (default prefix) Routes
router (/workspaces) GET / (workspaces the caller owns), POST / (create; multipart form), GET /available, GET /{workspace_id}, PUT /{workspace_id}, DELETE /{workspace_id}
user_workspace_router (/users) POST /join, POST /switch
user_me_router (/{workspace_slug}/users) GET /me, GET /{user_id}
user_profile_router (/{workspace_slug}/profiles) The profile routes of the account module, restricted to workspace members

POST, PUT and DELETE on /workspaces pass through role_check([AccountRole.admin]), an account-level check, and then act only on workspaces the caller owns. GET /available works without authentication and lists open workspaces; for a signed-in caller it also includes workspaces they belong to or own, minus locked workspaces and workspaces where they are banned.

Configuration

Settings live in the WORKSPACES namespace (WorkspaceSettings).

Key Default Notes
AUTO_CREATE_DEFAULT_WORKSPACE True The account hook creates the default workspace and membership.
DEFAULT_WORKSPACE_NAME Default Workspace Name of the default workspace.
AUTO_REGISTER_EVENT_HOOKS True Declared, currently has no effect (#278). It is a flag for your startup code, as in the wiring above. The library does not read it itself.
ENABLE_USERINFO_COLLECTION False Declared, but not read from your settings object (#278). It creates the UserInfo table and is read from the process environment at import time.
USERINFO_TABLE_NAME workspace_user_info Declared, but not read from your settings object (#278). Table name when the above is on, read from the process environment.
ENABLE_MULTI_WORKSPACE, ALLOW_WORKSPACE_REGISTRATION, MAX_WORKSPACES_PER_ACCOUNT True, False, 10 Declared, currently has no effect (#278): nothing in the module reads them, so they do not restrict anything.

Subclassed settings take flat names (DEFAULT_WORKSPACE_NAME=Home); composed settings (WORKSPACES: WorkspaceSettings = WorkspaceSettings()) accept WORKSPACES__DEFAULT_WORKSPACE_NAME as well. ENABLE_USERINFO_COLLECTION and USERINFO_TABLE_NAME are the exception: they are resolved when msflib.workspaces.models is first imported, from the environment (WORKSPACES__ENABLE_USERINFO_COLLECTION or the flat name) and not from your settings object, so set them in the process environment.

Behaviour may change (#280)

The nested NS__KEY environment style only works for composed (field) settings, not for subclassed hosts. With subclassed settings, use the flat names.

Key concepts

Membership and access

A membership is the only thing that connects an account to a workspace. User has a unique constraint on (account_id, workspace_id), a type (owner, admin, member, guest), a status (active, kicked, banned), and profile fields (display_name, avatar_url, bio, data).

The request dependencies in auth check this row. Having an account, owning the tenant, or holding an account-level admin role does not give access to a workspace: the account needs a membership in that workspace. A tenant administrator who needs to administer a workspace joins it like anyone else, or is added by an owner. Likewise, a workspace's rows are scoped by workspace_id, and None is the anonymous workspace, never "all workspaces" (see tenancy).

Ways memberships are created:

  • POST /workspaces/ makes the creator the owner.
  • The account hook (below) adds each new account to the default workspace.
  • POST /users/join lets an account join a workspace whose status is open. Only the roles in allowed_join_roles (default [UserType.member]) can be requested; asking for another role is a 403. Joining is idempotent and also sets the account's current_workspace_id.
  • UserAction.create_membership(...) creates one from code. It is idempotent per account and workspace.

POST /users/switch changes current_workspace_id to another workspace the account already belongs to, and refuses locked workspaces (423) or non-members (401).

Workspace model

WorkspaceBase fields: name, slug (unique, derived from the name), description, logo_url, owner_id (foreign key to account.id), tenant_id (foreign key to tenant.id), is_default, status, settings and data (JSON). WorkspaceStatus is open, restricted, readonly or locked.

Creating a workspace through the router rejects duplicate names, and names that match a reserved list: admin, accounts, workspaces, plus top-level route prefixes found by scanning ./testsite/app/api/api.py and ./testsite/app/api/api_v1/endpoints/admin/__init__.py relative to the working directory. Those files exist only in this repository's test site, so in a host app only the three built-in names apply unless you extend the check.

Known issue (#271)

The reserved-name scan hard-codes the test site's paths, so it finds nothing outside this repository. Related workspaces limitations tracked in the same issue: the default-workspace hook fires only when the account model class is named Account (see Default workspace hook), and decorate_access_token_payload adds fields that the default Token response model drops, so declare them on your own access_token_response_type (see auth).

Actions

WorkspaceAction(settings=...) adds, on top of ModelAction:

  • create_with_owner(session, data=, owner=, tenant=) sets owner_id and tenant_id for you.
  • ensure_default_workspace(session, owner_id=, tenant=) returns the tenant's default workspace, creating it if missing.
  • resolve_workspace_context(session, account=, user_action=), build_access_token_claims(...) and decorate_access_token_payload(...) compute the account's workspaces and active membership for login. Pass the last two to the auth router's access_token_claims_decorator and access_token_decorator hooks.

UserAction adds create_membership and get_by_email. Membership type is resolved in this order: the membership_type= argument to create_membership; a membership_type_resolver callable passed to UserAction(...) with signature (account_id, workspace_id, owner_id) -> type; an override of resolve_membership_type in a subclass; and finally the default, owner when the account is the workspace owner and member otherwise.

Default workspace hook

register_event_hooks(...) registers two listeners:

  • On account-create-pre-commit, if AUTO_CREATE_DEFAULT_WORKSPACE is true: look up the FIRST_SUPERUSER account (falling back to the new account) as owner, ensure the default workspace for the default tenant, add the new account as a member, and set its current_workspace_id if empty.
  • On workspace-create-pre-commit: add the workspace owner as a member.

That first event name comes from ModelAction, which emits <model-name>-create-pre-commit inside the creating transaction. The model name is the lower-cased class name, so the hook fires only if your account model class is named Account. A differently named class needs its own listener (see the known issue under Workspace model). Events fire on the emitter that is active for the current request or block; outside a request, wrap the code in use_app_emitter(app) from msflib.eventbus.

Membership events for your own listeners: workspace-membership-pre-create (strict: a failing or async listener aborts the join) and workspace-membership-created.

Overriding form handling

Workspace create and update take multipart/form-data so a logo can be uploaded. get_workspace_form_dependencies(workspace_create_type=, workspace_update_type=) returns a mutable namespace with get_workspace_create and get_workspace_update. Wrap or replace one and pass the namespace as workspace_form_dependencies= to router(...).

Examples

Seed a tenant, accounts, workspace and membership from code

from msflib.tenancy import TenantAction
from msflib.tenancy.models import TenantCreate, TenantUpdate
from msflib.tenancy.models.tenant import Tenant
from msflib.workspaces.models import UserType

tenant_action = TenantAction[Tenant, TenantCreate, TenantUpdate]()

with Session(engine) as session:
    tenant = tenant_action.ensure_default_tenant(session, settings=settings.scope("TENANCY"))
    ada = account_action.create(
        session, data=AccountCreate(email="ada@example.com", password="s3cret-pass")
    )
    acme = workspace_action.create_with_owner(
        session,
        data=workspace_action.random(name="Acme", description="Acme team", owner_id=ada.id),
        owner=ada,
        tenant=tenant,
    )
    user_action.create_membership(  # idempotent: returns the owner row if a hook already made it
        session, account_id=ada.id, workspace_id=acme.id, owner_id=ada.id
    )

    membership = user_action.get_by_all(session, account_id=ada.id, workspace_id=acme.id)
    assert membership.type == UserType.owner
    assert acme.tenant_id == tenant.id

What this leaves in the database depends on the emitter context. In a host where the workspaces hooks are registered, a plain session (no emitter context) creates only acme and leaves current_workspace_id unset. Inside with use_app_emitter(app): the hooks also run, so a default-workspace owned by ada is created, ada is a member of both workspaces and her current_workspace_id is set. The assertions pass either way.

Create, join and switch over HTTP

With an admin-role account root@example.com and a plain account bob@example.com, using the routers above:

POST /workspaces/        (admin, form: name=Beta, description=Beta team)  -> 201, slug "beta"
POST /users/join         (bob, {"workspace_id": 3, "user_type": "member"}) -> 200, workspace_slug "beta"
POST /users/join         (bob, {"workspace_id": 3, "user_type": "admin"})  -> 403 "The requested join role is not allowed."
GET  /beta/users/me      (bob)                                             -> 200, type "member"
POST /users/switch       (bob, {"workspace_id": 2})                        -> 401, not a member of workspace 2

Bootstrap a default workspace when accounts are created

Register the hooks (see the wiring above) and create accounts inside the app's emitter context:

from msflib.eventbus import use_app_emitter

with use_app_emitter(app), Session(engine) as session:
    tenant_action.ensure_default_tenant(session, settings=settings.scope("TENANCY"))
    account = account_action.create(
        session, data=AccountCreate(email="new@example.com", password="s3cret-pass")
    )
    account.current_workspace_id  # 1, the default workspace

With no FIRST_SUPERUSER account present, the new account becomes owner of the default workspace.

Customise the membership type

from msflib.workspaces.actions import UserAction


def my_resolver(account_id: int, workspace_id: int, owner_id: int) -> UserType:
    return UserType.owner if account_id == owner_id else UserType.guest


user_action = UserAction[User, UserCreate, UserUpdate](membership_type_resolver=my_resolver)

Troubleshooting

Symptom Cause and fix
NoReferencedTableError from create_all Account or Tenant is not imported. Import msflib.account.models.account and msflib.tenancy.models.tenant first.
500 "Default tenant has not been seeded" on POST /workspaces/ Seed the tenant at startup, or pass get_current_tenant.
422 "Workspace name not allowed" The slug matches admin, accounts, workspaces, or a reserved route prefix.
403 on POST /workspaces/ for a root account The route requires AccountRole.admin exactly. Use an account with that role, or a role-check factory that includes root.
403 "The requested join role is not allowed." The role is not in allowed_join_roles. Raise it only if you accept self-service joins at that level.
423 on join or switch Workspace status prevents it: join needs open, switch refuses locked.
New accounts get no default workspace The hooks were not registered, the account class is not named Account, AUTO_CREATE_DEFAULT_WORKSPACE is false, or no emitter is active for the code path.
UserInfo has no table ENABLE_USERINFO_COLLECTION was not set in the environment before the first import of the module.

API reference

See the generated API reference for msflib.workspaces. modules/workspaces/README.md documents the membership-type resolution options.

See also