Which module holds the user accounts depends on the auth provider: users
keeps them in users_user, keycloak keeps a cache of everyone who has signed
in. A module that only needs to name a user, look a few up, search them or
count them reads them through the user directory, a protocol in
simple_module_core.user_directory, and never imports users.models. That
keeps it working under either provider instead of silently querying an empty
table on a Keycloak install (GH #501).
from simple_module_core.user_directory import get_user_directory
directory = get_user_directory(request.app)
found = await directory.get_many(db, ["3f0c…", "celery-worker"]) # {id: DirectoryUser}| Member | Returns |
|---|---|
get_many(db, ids, *, with_roles=False) |
{id: DirectoryUser} keyed by your id strings. Ids that are not uuids or name nobody are absent, never an error. Implementations chunk (GET_MANY_CHUNK, 500 ids per query). |
search(db, term, *, limit) |
Users whose name or email contains term, ordered by email; everyone up to limit when term is empty. |
match_ids(term) |
An unexecuted SELECT of the matching text ids, for use as an unbounded subquery (the audit log's Actor filter). None when the provider cannot express it. |
stats(db, *, active_window) |
DirectoryStats(total, active_since, created_this_month); a None field means "cannot say", not zero. |
entity_type |
The audit-link key user rows are recorded under ("User"), or None when they have no audited model to link to. |
DirectoryUser carries id, email, full_name, is_active (false for a
disabled account too) and roles: a tuple of role names when with_roles=True
and the provider knows them, otherwise None. None means unknown, not "no
roles": Keycloak keeps roles in the IdP, so code that would act on them
(personal API tokens) must refuse rather than treat the user as role-less.
Name a user with DirectoryUser.label (the full name, else the email). It is
the one naming rule, so a row is never called two things on one screen.
resolve_user_labels(directory, db, ids) maps a page of ids to labels in one
call; parse_user_ids and chunked help an implementation key and bound its
queries. get_many is keyed by the caller's strings: parse_user_ids maps each
uuid to every spelling the caller passed (ABC…, abc…, unhyphenated), and an
implementation returns an entry under each one.
The active auth provider installs its directory in register_settings with
set_user_directory(app, directory):
| Provider | Directory | Knows |
|---|---|---|
users |
users.core.directory.UsersDirectory |
every local account, roles via users_user_role, all three stats |
keycloak |
keycloak.identity.directory.KeycloakDirectory |
users who have signed in at least once (keycloak_user_cache); roles None; created_this_month None; entity_type None |
| none | NullUserDirectory |
nobody; every stat None; match_ids None |
A new provider implements the four methods and entity_type, and registers
it. Ids must come back in the canonical hyphenated text form the rest of the
app stores (str(uuid)); KeycloakDirectory shows how to render that on
SQLite, where SQLAlchemy's Uuid is 32 bare hex characters.
| Module | Reads |
|---|---|
audit_log |
actor names (resolve_actors), the Actor search (actor_filter, a subquery via match_ids, an exact id match without one) and actor links (keyed by entity_type) |
dashboard |
the user tiles from stats(); a None tile is hidden |
file_storage |
uploader names |
api_tokens |
owner checks and emails; personal-token resolution via get_many(with_roles=True) |
permissions |
the user side of the grants screens (get_many(with_roles=True), search) |
permissions role pages still depend on Users: roles have no directory
protocol yet (the Role table is read from users.contracts.roles).
api_tokens lists Users in optional_depends_on only: a personal token
needs the owner's roles, so under Keycloak it is refused while service keys
keep working.
The users edit page shows what an account did lately. That comes from the
audit log, but users and audit_log must not import each other (each is
optional to the other, and importing both ways was a cycle). So
simple_module_core.activity_feed defines an ActivityFeed protocol:
from simple_module_core.activity_feed import get_activity_feed
feed = get_activity_feed(request.app) # None: nothing records activity
items = await feed.recent_for_actor(db, user_id, limit=6, permissions=granted)Each ActivityItem has at, action, entity_type, entity_id,
entity_label (or None when the owner cannot name the row, or the reader
lacks its AuditLink.label_permission) and the raw changes. audit_log
installs the feed in register_settings; set_activity_feed(app, None)
removes it. A missing feed renders no card, which is a different claim from
an empty one.