Skip to content

msflib-account

Purpose

msflib-account provides the account and profile domain: models, schemas, actions, and three routers for self-service, admin management and profiles. An account is the person who signs in. It is independent of any tenant or workspace; per-workspace identity lives in msflib-workspaces as a membership row that points at an account.

Use it with msflib-auth, which supplies the get_current_account and role-check dependencies these routers require.

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/account/__init__.py exports nothing. Import from submodules. The abstract bases and schemas are re-exported from msflib.account.models; the concrete table classes are not, so import them from msflib.account.models.account:

from msflib.account.models import AccountCreate, AccountRead, AccountUpdate
from msflib.account.models.account import Account, Profile

Wiring into a host app

Create the tables, then mount the routers. This example uses the module's own Account and Profile models and an in-memory SQLite database.

from fastapi import FastAPI
from msflib.account.config import AccountSettings
from msflib.account.models import AccountRead
from msflib.account.models.account import Account, Profile
from msflib.account.router import account_admin_router, account_router, profile_router
from msflib.auth.config import AuthSettings
from msflib.auth.deps import get_account_dependencies
from msflib.auth.router import router as auth_router
from msflib.core.config import CoreSettings, SettingsBase
from msflib.core.store import MapStore
from msflib.eventbus import bind_app_emitter
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine


class Settings(AccountSettings, AuthSettings, CoreSettings, SettingsBase):
    SECRET_KEY: str = "change-me"
    USERS_OPEN_REGISTRATION: bool = True
    EMAILS_ENABLED: bool = False


settings = Settings()
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
SQLModel.metadata.create_all(engine)
keystore = MapStore()


def get_session():
    with Session(engine) as session:
        yield session


def get_keystore():
    return keystore


app = FastAPI()
app_emitter = bind_app_emitter(app)

auth = get_account_dependencies(
    AccountModel=Account,
    oauth_token_url="/api/v1/auth/login",
    secret_key=settings.SECRET_KEY,
    session_dep=get_session,
    keystore_dep=get_keystore,
)
app.include_router(
    auth_router(
        get_session=get_session,
        get_keystore=get_keystore,
        get_current_account=auth.get_current_account,
        account_type=Account,
        account_read_type=AccountRead,
        settings=settings,
        prefix="/api/v1/auth",
    )
)
app.include_router(
    account_router(
        get_session=get_session,
        get_current_account=auth.get_current_account,
        settings=settings,
        account_type=Account,
        profile_type=Profile,
        prefix="/api/v1/account",
    )
)
app.include_router(
    account_admin_router(
        get_session=get_session,
        role_check=auth.RoleCheck,
        settings=settings,
        account_type=Account,
        profile_type=Profile,
    )
)
app.include_router(
    profile_router(
        get_session=get_session,
        get_current_account=auth.get_current_account,
        get_current_active_account=auth.get_current_active_account,
        settings=settings,
        account_type=Account,
        profile_type=Profile,
    )
)

bind_app_emitter(app) activates the event bus for requests; see Event bus.

Routes

Router (default prefix) Routes
account_router (/account) GET /me, PUT /me, POST /open (open registration, when enabled), POST /verify-availability
account_admin_router (/admin/accounts) GET /, POST /, GET /{account_id}, PUT /{account_id}. All require role_check(roles=[AccountRole.admin]).
profile_router (/profiles) GET /, GET /{account_id}, POST /, PUT /, PUT /avatar, PUT /{id}

The admin router calls role_check(roles=[...]) with a roles= keyword. RoleCheck from msflib-auth accepts that. If you pass your own factory, give it a roles parameter.

Configuration

Settings live in the ACCOUNT namespace (AccountSettings).

Key Default Notes
USERS_OPEN_REGISTRATION False When false, POST /account/open returns 403.
OPEN_REGISTRATION_PATH /open Path of the registration route under the router prefix.
FIRST_SUPERUSER admin@example.com Email of the bootstrap admin. The workspaces hooks use it to find the owner of the default workspace.
FIRST_SUPERUSER_PASSWORD password Development default. Override it.
EMAIL_TEST_ACCOUNT admin@example.com Used by test helpers.

With subclassed settings (as above) set these with flat names such as USERS_OPEN_REGISTRATION=true. With composed settings (ACCOUNT: AccountSettings = AccountSettings()) the nested form ACCOUNT__USERS_OPEN_REGISTRATION=true works too. Code reads values with settings.scope("ACCOUNT").

The wiring above sets defaults by overriding fields on a subclassed Settings class. With composed settings, set them from the environment instead (ACCOUNT__USERS_OPEN_REGISTRATION=true); without it open registration returns 403. Composed settings also hit the auth router issue described on the auth page (#287), which breaks login.

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.

Account creation sends a welcome email when CORE.EMAILS_ENABLED is true. That setting defaults to true whenever the SMTP host, port and from-address are set, and the defaults are set, so disable it in development unless an SMTP server is available: EMAILS_ENABLED=false with subclassed settings, CORE__EMAILS_ENABLED=false with composed settings (the flat name is ignored there).

Key concepts

Models

  • AccountBase fields: username, email (unique, stored lower-case), phone, status, role, data (JSON), hashed_password, last_login_date, current_workspace_id.
  • AccountStatus: online, active, suspended, banned. AccountRole: root, admin, user.
  • current_workspace_id is a plain integer column, not a foreign key, so the account module does not depend on workspaces. It records which workspace the account last selected; auth reads it to resolve the current workspace.
  • ProfileBase shares its primary key with the account (id is a foreign key to account.id): one profile per account.
  • Schemas: AccountCreatePublic (client input for open registration), AccountCreate (adds status, role, nested profile), AccountRead, AccountReadPublic, AccountUpdate, and the profile equivalents.

Subclass AccountBase with table=True to add columns, and pass your class as account_type. See Overriding models.

Actions

AccountAction and ProfileAction are generic ModelAction subclasses; parameterise them with your model and schema types:

from msflib.account.actions import AccountAction, ProfileAction
from msflib.account.models import AccountCreate, AccountUpdate, ProfileCreate, ProfileUpdate

profile_action = ProfileAction[Profile, ProfileCreate, ProfileUpdate]()
account_action = AccountAction[Account, AccountCreate, AccountUpdate](
    settings=settings,
    profile_action=profile_action,
)

AccountAction adds get_by_email, authenticate, ensure_unique_fields (checks email, phone and username by default), and create, which hashes the password, lower-cases the email and, when a profile_action is configured and the data has a profile, creates the profile in the same transaction. Constructing it without settings still works but emits a DeprecationWarning.

AccountAction.resolve_workspace_context, build_access_token_claims and decorate_access_token_payload are stubs kept for compatibility and return empty values. The working versions are on WorkspaceAction in workspaces.

The msflib.account.service.account module offers create_account, update_account and get_account, which the routers use; call them if you need the same event and email behaviour outside a route.

Events

Names are in msflib.account.eventbus.EventName:

Event Emitted
account-pre-create, account-created-pre-email Inside create_account, so on open registration and admin creation.
account-created After open registration only. The admin create route does not emit it.
account-pre-update, account-updated On PUT /account/me.
profile-pre-create, profile-created, profile-pre-update, profile-updated, profile-avatar-pre-update, profile-avatar-updated By the profile service functions.

Register your own listeners on the app emitter, for example @app_emitter.on("account-created").

Separately, every ModelAction emits lifecycle events named after the model, such as account-create-pre-commit; the workspaces module uses that one.

Workspace contracts

msflib.account.contracts exports two Protocol classes, WorkspaceContract (needs id) and UserContract (needs id, account_id, workspace_id). Type-hint against them when your code should work with a workspace or membership without importing msflib-workspaces.

Examples

Register, sign in and read the current account

Using the app from the wiring section:

from fastapi.testclient import TestClient

client = TestClient(app)

r = client.post(
    "/api/v1/account/open",
    json={"email": "Ada@Example.com", "password": "s3cret-pass", "profile": {"first_name": "Ada"}},
)
assert r.status_code == 201
assert r.json()["email"] == "ada@example.com"

r = client.post(
    "/api/v1/auth/login",
    data={"username": "ada@example.com", "password": "s3cret-pass"},
)
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}

client.get("/api/v1/account/me", headers=headers).json()["email"]  # "ada@example.com"
client.get("/admin/accounts/", headers=headers).status_code  # 403: role is "user"

Create an account from code

with Session(engine) as session:
    account = account_action.create(
        session,
        data=AccountCreate(email="ops@example.com", password="s3cret-pass", role="admin"),
    )

account_action.random(...) builds a populated AccountCreate for tests and seed scripts.

Use a custom registration schema

Pass account_create_type= to account_router and account_admin_router to validate request bodies against a subclass of AccountCreate. The route path and response model do not change.

from msflib.account.models import AccountCreate


class SignupRequest(AccountCreate):
    referral_code: str | None = None


app.include_router(
    account_router(
        get_session=get_session,
        get_current_account=auth.get_current_account,
        settings=settings,
        account_type=Account,
        profile_type=Profile,
        account_create_type=SignupRequest,
        open_registration_path="/signup",
    )
)

open_registration_path overrides OPEN_REGISTRATION_PATH. Add the extra column on your account_type model if you want referral_code stored; a listener on account-pre-create can also act on it.

Troubleshooting

Symptom Cause and fix
POST /account/open returns 403 USERS_OPEN_REGISTRATION is false.
422 "An account with this email already exists in the system" Uniqueness is checked on email, phone and username. Narrow it with unique_account_fields=[...].
Account creation logs an email send, or fails with a connection error EMAILS_ENABLED is true with the default localhost:1025 SMTP settings. Disable it or configure SMTP.
Nested profile is ignored on creation The AccountAction has no profile_action. A custom account_action must be given one.
GET /admin/accounts/ is 403 for a root account The routes require AccountRole.admin exactly. Give your role-check factory a list that includes root, or pass your own role_check.
Foreign key error creating a profile on PostgreSQL Profile id equals the account id; create the account first.
ImportError: cannot import name 'Account' from 'msflib.account.models' Import the table classes from msflib.account.models.account.

API reference

See the generated API reference for msflib.account. modules/account/README.md has more detail on open registration and nested profiles.

See also