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¶
AccountBasefields: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_idis 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 thecurrentworkspace.ProfileBaseshares its primary key with the account (idis a foreign key toaccount.id): one profile per account.- Schemas:
AccountCreatePublic(client input for open registration),AccountCreate(addsstatus,role, nestedprofile),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.