msflib-workspace-config¶
Purpose¶
msflib-workspace-config stores configuration values at three levels, tenant, workspace and user (an account inside a workspace), and resolves them over a module's default settings. It is the persistence side of tiered configuration: the merge rules live in core, and this module supplies the table, the service that reads and writes values, and an adapter for the shared policy layer.
It also ships a small workspace-scoped categories API (categories and tags) that doubles as a reference for building a workspace-scoped feature.
For the model of tiers and precedence, see Tiered configuration and Policy.
Install¶
[tool.poetry.dependencies]
msflib = { git = "https://github.com/msflib/fastapi.git", subdirectory = "core", rev = "core-v0.2.1" }
msflib-workspace-config = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/workspace_config", rev = "workspace_config-v0.2.1" }
The package depends on msflib-tenancy. Its tables also have foreign keys to account.id and workspace.id, so add msflib-account and msflib-workspaces when you create the schema. Their metadata must be loaded before create_all.
Packaging issue (#279)
The package does not declare its foreign-key dependencies on the account and workspaces modules. Add msflib-account and msflib-workspaces to your own dependencies yourself, as above.
msflib/workspace_config/__init__.py exports nothing. Import from msflib.workspace_config.service, .models, .actions, .router, .config.
Wiring into a host app¶
The service needs no mounting: construct it and call it with a session.
from msflib.workspace_config.models import ConfigScopeType
from msflib.workspace_config.service import ScopedConfigService
config_service = ScopedConfigService()
To expose the categories API, mount its router. It takes the workspace and membership dependencies from auth:
from msflib.workspace_config.router import router as workspace_config_router
app.include_router(
workspace_config_router(
get_session=get_session,
settings=settings,
get_current_workspace=user_deps.get_current_workspace,
get_current_workspace_anonymous=user_deps.get_current_workspace_anonymous,
get_current_active_user=user_deps.get_current_active_user,
workspace_role_check=user_deps.WorkspaceRoleCheck,
prefix="/{workspace_slug}/config",
)
)
The prefix must contain the path parameter your workspace resolver reads ({workspace_slug} for the default PathParameterResolver). Routes:
| Route | Access |
|---|---|
GET /health |
Open. Returns {"module": "workspace_config", "enabled": ...}. |
GET /categories |
Anonymous workspace resolution (any caller who can name the workspace). |
POST /categories, PUT /categories/{id}, DELETE /categories/{id} |
Active membership whose role is exactly admin. |
The category writes call workspace_role_check(["admin"]), which compares the membership type to admin only. A workspace owner is refused with 403. Make the owner an admin as well, or pass a role-check factory that lists owner too.
Configuration¶
WorkspaceConfigSettings (namespace WORKSPACE_CONFIG) declares no keys of its own. It inherits ENABLED (default True) from ModuleSettingsBase, which GET /health reports. With composed settings the environment variable is WORKSPACE_CONFIG__ENABLED; with subclassed settings it is ENABLED, which is shared with every other module mixed into the same class. Code reads it with settings.scope("WORKSPACE_CONFIG").
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 name.
The service itself is configured by the module settings objects you pass to it (for example AICoreSettings). See Key concepts.
Key concepts¶
Scope types and storage keys¶
Each row of ScopedConfigEntry holds one namespace of values for one scope:
ConfigScopeType |
Required ids | Meaning |
|---|---|---|
tenant |
tenant_id |
Defaults for a whole tenant |
workspace |
workspace_id |
Overrides for one workspace |
user |
workspace_id and account_id |
One account's overrides in one workspace |
The service raises ValueError if a required id is missing. Every entry has a unique storage_key built from the scope type, the upper-cased namespace, and the tenant, workspace and account ids in that order, with - for an absent id, for example workspace:DEMO:-:2:- and user:DEMO:-:2:1. The three ids are encoded as parallel dimensions in the key, and an absent one is encoded as an explicit empty slot. It is not a wildcard. Namespaces are normalised to upper case, so demo and DEMO are the same entry.
Resolution order¶
resolve_module_settings(session, module_settings, tenant_id=, workspace_id=, account_id=, request_override=) returns a new settings object of the same class. Later layers win:
- The defaults on the
module_settingsinstance you pass. - The tenant entry for the module's
namespace. - The workspace entry, merged over the tenant layer.
- The user entry.
request_override, either a plain mapping or{"overrides": {...}}.
A tier is skipped if its ids are not given (the user tier needs both workspace_id and account_id).
If the settings class defines request_override_allowlist (a set[str] class variable), request overrides may only touch those keys; anything else raises ValueError("Unsupported request override keys: ..."). Use it to keep secrets out of reach of per-request overrides. Without an allowlist, every key is accepted.
Reading and writing values¶
ScopedConfigService methods all take a session, a ConfigScopeType, a namespace and the ids for that scope:
| Method | Behaviour |
|---|---|
get_namespace_values / put_namespace_values |
Read or replace the whole values dict. put creates or updates the entry. |
get_value / set_value |
Read one key (with default=) or set one key, keeping the others. |
delete_value |
Remove one key. Removing the last key deletes the entry and returns None. |
resolve_module_settings |
The merge described above. |
Pass ScopedConfigService(action=...) to use a subclass of ScopedConfigAction.
Policy layer adapter¶
ScopedConfigPolicyStoreAdapter(service) implements the core PolicyScopedConfigStore protocol (get_tenant_namespace_values, get_workspace_namespace_values, get_user_namespace_values). Hand it to msflib.policy's resolution service to resolve effective policy from stored values and record decision traces.
Categories¶
WorkspaceCategory and Tag are two tables with the same shape (name, title, description, tags, order, workspace_id) and a unique (name, workspace_id) constraint. WorkspaceCategoryAction and TagAction are the matching actions; the router uses WorkspaceCategoryAction unless you pass category_action=, and accepts category_create_type= and category_update_type= to swap the request schemas.
Examples¶
Store tiered values and resolve them¶
This runs against an in-memory SQLite database, after the models from account, tenancy and workspaces are imported and the tables created. tenant, acme and ada are an existing tenant, workspace and account. These examples pass a bare DemoSettings(), which carries class defaults only and does not read the environment; to merge environment values, pass settings.scope("DEMO") (subclassed) or settings.DEMO (composed) from your host settings instead. On PostgreSQL the tenant, workspace and account ids must exist as rows.
from typing import ClassVar
from msflib.core.config import ModuleSettingsBase
from msflib.workspace_config.models import ConfigScopeType
from msflib.workspace_config.service import ScopedConfigService
class DemoSettings(ModuleSettingsBase):
namespace: ClassVar[str] = "DEMO"
request_override_allowlist: ClassVar[set[str]] = {"LIMIT"}
LIMIT: int = 10
MODE: str = "default"
svc = ScopedConfigService()
with Session(engine) as s:
svc.put_namespace_values(
s, ConfigScopeType.tenant, "demo", {"LIMIT": 20, "MODE": "tenant"}, tenant_id=tenant.id
)
svc.set_value(s, ConfigScopeType.workspace, "demo", "MODE", "workspace", workspace_id=acme.id)
svc.set_value(
s, ConfigScopeType.user, "demo", "LIMIT", 5, workspace_id=acme.id, account_id=ada.id
)
for_ada = svc.resolve_module_settings(
s, DemoSettings(), tenant_id=tenant.id, workspace_id=acme.id, account_id=ada.id
)
(for_ada.LIMIT, for_ada.MODE) # (5, "workspace")
for_workspace = svc.resolve_module_settings(
s, DemoSettings(), tenant_id=tenant.id, workspace_id=acme.id
)
(for_workspace.LIMIT, for_workspace.MODE) # (20, "workspace")
per_request = svc.resolve_module_settings(
s,
DemoSettings(),
tenant_id=tenant.id,
workspace_id=acme.id,
account_id=ada.id,
request_override={"LIMIT": 1},
)
per_request.LIMIT # 1
svc.resolve_module_settings(s, DemoSettings(), request_override={"MODE": "x"})
# ValueError: Unsupported request override keys: MODE
Resolve settings inside a request¶
Combine the service with the scope dependencies so the ids come from the authenticated caller:
from fastapi import Depends
@app.get("/{workspace_slug}/limit")
def limit(
session=Depends(get_session),
workspace=Depends(user_deps.get_current_workspace),
user=Depends(user_deps.get_current_active_user),
):
resolved = config_service.resolve_module_settings(
session,
DemoSettings(),
tenant_id=workspace.tenant_id,
workspace_id=workspace.id,
account_id=user.account_id,
)
return {"limit": resolved.LIMIT}
Delete a value¶
with Session(engine) as s:
svc.delete_value(s, ConfigScopeType.workspace, "demo", "MODE", workspace_id=acme.id)
svc.get_namespace_values(s, ConfigScopeType.workspace, "demo", workspace_id=acme.id) # {}
Troubleshooting¶
| Symptom | Cause and fix |
|---|---|
no such table: scopedconfigentry |
The models were not imported before create_all. Import msflib.workspace_config.models first, or run your migrations. |
NoReferencedTableError creating tables |
The account, tenant or workspace models are not loaded. Import them before create_all. |
ValueError: user scope requires workspace_id and account_id |
A user-tier call omitted one of the ids. |
ValueError: Unsupported request override keys |
The key is not in the settings class's request_override_allowlist. |
| A stored value has no effect | The namespace must equal the settings class's namespace. Namespaces are compared upper-cased, and keys inside must match the field names. |
Workspace owner gets 403 on POST /categories |
The route admits membership type admin only. See the note above. |
| Router call fails with missing arguments | The router requires get_current_workspace, get_current_workspace_anonymous, get_current_active_user and workspace_role_check in addition to get_session and settings. |
API reference¶
See the generated API reference for msflib.workspace_config. modules/workspace_config/README.md has notes on the request override allowlist and the policy adapter.