Skip to content

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:

  1. The defaults on the module_settings instance you pass.
  2. The tenant entry for the module's namespace.
  3. The workspace entry, merged over the tenant layer.
  4. The user entry.
  5. 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.

See also