From 0a3109d70ac1b098cb454994df2a11c9eff633ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sun, 11 Oct 2026 18:27:43 +0200 Subject: [PATCH] feat: generate the tables and the mappings of a provider --- doc/changelog.rst | 5 + doc/explanation.rst | 4 + doc/how-to/generate-the-tables.rst | 163 +++++++ doc/how-to/index.rst | 1 + doc/how-to/map-a-data-model.rst | 4 + doc/index.rst | 2 + doc/reference/mapping.rst | 7 +- src/scim2_sqlalchemy/__init__.py | 4 + src/scim2_sqlalchemy/generation.py | 727 +++++++++++++++++++++++++++++ src/scim2_sqlalchemy/mapping.py | 32 ++ tests/test_generation.py | 583 +++++++++++++++++++++++ 11 files changed, 1531 insertions(+), 1 deletion(-) create mode 100644 doc/how-to/generate-the-tables.rst create mode 100644 src/scim2_sqlalchemy/generation.py create mode 100644 tests/test_generation.py diff --git a/doc/changelog.rst b/doc/changelog.rst index 9ac71ed..309d931 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -22,6 +22,11 @@ Added ``asyncio`` extra. - :meth:`ResourceMapping.schemas `, which publishes the attributes a mapping stores. +- :func:`~scim2_sqlalchemy.generate_mappings`, which generates the tables and the mappings of + the resource types of a provider. A function of the application decides whether each attribute + is stored, and with which column type. +- :class:`~scim2_sqlalchemy.Hashed`, a column type storing the hash of a value, such as a + password. - Filters, sorts and uniqueness checks compare strings with the comparison key of the policy of the provider: exactly on SQLite, and approximately on PostgreSQL. - A column of type :class:`~sqlalchemy.types.Enum` holds the string it stores: the name of the diff --git a/doc/explanation.rst b/doc/explanation.rst index 04c1019..41e4f85 100644 --- a/doc/explanation.rst +++ b/doc/explanation.rst @@ -15,6 +15,10 @@ storage maps these tables as they are, rather than bringing tables of its own. A for SCIM would hold the resources as JSON documents, which databases filter and index unevenly, and would leave the application with two copies of its users. +A server without data of its own, such as a test server, can generate its tables with +:func:`~scim2_sqlalchemy.generate_mappings`. They hold a column for each attribute, as tables +designed by hand would, and the same mapping serves them. + A single declaration, the :class:`~scim2_sqlalchemy.ResourceMapping`, serves every task of the storage: reading a record, writing it, filtering and sorting. A storage written by hand describes the tables twice, once for the conversions and once for the queries, and the two diff --git a/doc/how-to/generate-the-tables.rst b/doc/how-to/generate-the-tables.rst new file mode 100644 index 0000000..e741e7a --- /dev/null +++ b/doc/how-to/generate-the-tables.rst @@ -0,0 +1,163 @@ +Generate the tables +=================== + +This guide generates the tables and the mappings of a SCIM server from its resource types. It is +for developers who start a server without a database of their own, such as a test server or an +application whose data model is SCIM. It assumes a :class:`~scim2_models.ScimProvider` +describing the resource types. To serve the tables of an existing application instead, follow +the :doc:`../overview` and :doc:`map-a-data-model`. + +Generate the mappings +--------------------- + +Call :func:`~scim2_sqlalchemy.generate_mappings` with the provider and a +:class:`~sqlalchemy.schema.MetaData`. It adds the tables to the metadata, and returns a +:class:`~scim2_sqlalchemy.ResourceMapping` for each resource type, by name: + +.. doctest:: + + >>> import hashlib + >>> import os + >>> from sqlalchemy import MetaData + >>> from scim2_server.utils import load_default_provider + >>> from scim2_sqlalchemy import Hashed + >>> from scim2_sqlalchemy import generate_mappings + + >>> def hash_password(password): + ... salt = os.urandom(16) + ... digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1) + ... return f"{salt.hex()}:{digest.hex()}" + + >>> PASSWORD = "urn:ietf:params:scim:schemas:core:2.0:User:password" + >>> def store(binding): + ... return Hashed(hash_password) if binding.urn == PASSWORD else True + + >>> provider = load_default_provider() + >>> metadata = MetaData() + >>> mappings = generate_mappings(provider, metadata, store=store) + >>> sorted(mappings) + ['Group', 'User'] + +Build the provider from the models of the application, or from the schemas and the resource +types of a configuration file: + +- ``ScimProvider(models=[User, EnterpriseUser, Group], resource_types=[...])``; +- :meth:`ScimProvider.from_discovery `. + +Choose what is stored +--------------------- + +Pass ``store``, a function deciding how each attribute is kept. It receives the +:class:`~scim2_models.AttributeBinding` of the attribute, with its URN, and returns: + +- :data:`True`, to store the attribute with the column type matching it; +- :data:`False`, not to store it: the published schemas leave it out; +- a column type, to store the attribute in a column of this type. Only an attribute with a + column of its own takes one: a simple attribute, a sub-attribute, or the values of a + multi-valued simple attribute. + +Without ``store``, every attribute is stored with the column type matching it. + +A write-only string, such as ``password``, is never stored in clear text: ``store`` gives it a +column type that hashes it, such as :class:`~scim2_sqlalchemy.Hashed`, or does not store it. +Otherwise, :func:`~scim2_sqlalchemy.generate_mappings` raises a :class:`ValueError`. The +application checks a password against the stored hash with the same hash function, so the choice +of the algorithm belongs to the application. + +A server loading its schemas at run time does not know their URNs in advance. It can decide on +the annotations of the attribute instead, here to hash every write-only string: + +.. code-block:: python + + from scim2_models import Mutability + + def store(binding): + if binding.get_annotation(Mutability) == Mutability.write_only: + return Hashed(hash_password) + return True + +A column type can also convert the values both ways, such as a +:class:`~sqlalchemy.types.TypeDecorator` encrypting them. Filters then compare the converted +values: ``eq`` works when the conversion always gives the same result, but ``sw``, ``co`` and the +sorts do not. A column type converting the values one way only, as a hash does, suits a +write-only attribute only, since the storage would return the converted values. + +Create the tables +----------------- + +Create the tables with :meth:`MetaData.create_all `, or +with a migration tool such as Alembic. The names of the tables and of the columns only depend on +the schemas, so the migration tool finds the same names from a run to the next: + +.. doctest:: + + >>> from sqlalchemy import create_engine + + >>> engine = create_engine("sqlite://") + >>> metadata.create_all(engine) + >>> sorted(metadata.tables)[:4] + ['groups', 'groups_members_groups', 'groups_members_users', 'users'] + +Pass ``prefix`` to start the name of every table with it, such as ``prefix="scim_"``, so that +the tables of SCIM stay apart from the other tables of the database. + +Serve the resource types +------------------------ + +Pass the mappings to the storage. Build the provider of the server from the schemas of the +mappings, so that it publishes the attributes the tables store, as +:doc:`publish-the-stored-attributes` explains: + +.. doctest:: + + >>> from scim2_models import ScimProvider + >>> from scim2_server.applications.wsgi import WSGIApplication + >>> from scim2_server.utils import load_default_service_provider_config + >>> from sqlalchemy.orm import Session + >>> from werkzeug.test import Client + >>> from scim2_sqlalchemy import SqlAlchemyStorage + + >>> published = ScimProvider.from_discovery( + ... [schema for mapping in mappings.values() for schema in mapping.schemas()], + ... provider.resource_types, + ... config=load_default_service_provider_config(), + ... ) + >>> session = Session(engine) + >>> storage = SqlAlchemyStorage(mappings, lambda: session, provider=published) + >>> client = Client(WSGIApplication(storage, published)) + >>> user = client.post( + ... "/v2/Users", json={"userName": "bjensen", "password": "correct horse"} + ... ).json + >>> group = client.post( + ... "/v2/Groups", json={"displayName": "admins", "members": [{"value": user["id"]}]} + ... ).json + >>> client.get(f"/v2/Users/{user['id']}").json["groups"] + [{'value': '...', '$ref': 'http://localhost/v2/Groups/...', 'display': 'admins', 'type': 'direct'}] + >>> session.close() + >>> engine.dispose() + +Read the inverse of an attribute +-------------------------------- + +A read-only attribute is not stored, except an inverse: it reads the links of another attribute. +The ``groups`` of a user reads the ``members`` of the groups. Declare the other inverses of the +resource types with ``inverses``, by the URN of each attribute: + +.. code-block:: python + + generate_mappings( + provider, + metadata, + store=store, + inverses={ + "urn:example:params:scim:schemas:extension:staffing:2.0:User:projects": ( + "urn:example:params:scim:schemas:core:2.0:Project:members" + ), + }, + ) + +The inverse links to the resource type of the attribute it reverses, which links to the resource +type of the inverse. Both are multi-valued. + +For the tables and columns each kind of attribute gets, and for the attributes left out, read +:func:`~scim2_sqlalchemy.generate_mappings`. diff --git a/doc/how-to/index.rst b/doc/how-to/index.rst index 1516abc..faef1b1 100644 --- a/doc/how-to/index.rst +++ b/doc/how-to/index.rst @@ -8,6 +8,7 @@ the :doc:`../overview`. :maxdepth: 1 map-a-data-model + generate-the-tables publish-the-stored-attributes manage-the-transactions compare-strings diff --git a/doc/how-to/map-a-data-model.rst b/doc/how-to/map-a-data-model.rst index 206ea5f..e9dbaee 100644 --- a/doc/how-to/map-a-data-model.rst +++ b/doc/how-to/map-a-data-model.rst @@ -141,6 +141,10 @@ The mapping makes the password write-only: {"password": Attribute(Account.password, readable=False)} +A column of type :class:`~scim2_sqlalchemy.Hashed` hashes the values the same way, without a +property: ``mapped_column(Hashed(hash_password))``. The mapping refuses it unless +``readable=False``. + A write-only attribute keeps its stored value when a request does not carry it, since a resource never carries it back. diff --git a/doc/index.rst b/doc/index.rst index 4dd800c..ed377d8 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -40,6 +40,8 @@ Start with the page that matches the need: - To build a SCIM server over the tables of an existing application, follow the :doc:`overview`. +- To build a SCIM server without tables of its own, generate them from the resource types: read + :doc:`how-to/generate-the-tables`. - To map other data models, publish the stored attributes, commit the changes of each request, or compare strings with a custom comparison key, read the :doc:`how-to guides `. - For the reasons behind the behavior of the storage, read :doc:`explanation`. diff --git a/doc/reference/mapping.rst b/doc/reference/mapping.rst index 06e089f..57d3e1e 100644 --- a/doc/reference/mapping.rst +++ b/doc/reference/mapping.rst @@ -1,13 +1,18 @@ Mapping ======= -The classes describing how SCIM resources are stored in SQLAlchemy models. +The classes describing how SCIM resources are stored in SQLAlchemy models, and the function +generating them from the resource types. .. autoclass:: scim2_sqlalchemy.ResourceMapping :members: schemas .. autoclass:: scim2_sqlalchemy.Attribute +.. autoclass:: scim2_sqlalchemy.Hashed + .. autoclass:: scim2_sqlalchemy.Many .. autoclass:: scim2_sqlalchemy.Link + +.. autofunction:: scim2_sqlalchemy.generate_mappings diff --git a/src/scim2_sqlalchemy/__init__.py b/src/scim2_sqlalchemy/__init__.py index cfa0605..7aeea3c 100644 --- a/src/scim2_sqlalchemy/__init__.py +++ b/src/scim2_sqlalchemy/__init__.py @@ -1,4 +1,6 @@ +from .generation import generate_mappings from .mapping import Attribute +from .mapping import Hashed from .mapping import Link from .mapping import Many from .mapping import ResourceMapping @@ -8,8 +10,10 @@ __all__ = [ "AsyncSqlAlchemyStorage", "Attribute", + "Hashed", "Many", "Link", "ResourceMapping", "SqlAlchemyStorage", + "generate_mappings", ] diff --git a/src/scim2_sqlalchemy/generation.py b/src/scim2_sqlalchemy/generation.py new file mode 100644 index 0000000..bde5f5b --- /dev/null +++ b/src/scim2_sqlalchemy/generation.py @@ -0,0 +1,727 @@ +import hashlib +import re +import uuid +from collections.abc import Callable +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from scim2_models import Attribute as SchemaAttribute +from scim2_models import AttributeBinding +from scim2_models import Mutability +from scim2_models import Path +from scim2_models import Required +from scim2_models import ResourceType +from scim2_models import ScimProvider +from scim2_models import Uniqueness +from sqlalchemy import Boolean +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import Float +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import LargeBinary +from sqlalchemy import MetaData +from sqlalchemy import String +from sqlalchemy import Table +from sqlalchemy import Uuid +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import mapped_column +from sqlalchemy.orm import relationship +from sqlalchemy.types import TypeEngine + +from .mapping import Attribute +from .mapping import Link +from .mapping import Many +from .mapping import ResourceMapping + +_GROUPS = "urn:ietf:params:scim:schemas:core:2.0:User:groups" +_MEMBERS = "urn:ietf:params:scim:schemas:core:2.0:Group:members" + +_MAX_NAME_LENGTH = 63 +"""The longest identifier PostgreSQL keeps.""" + +_SQL_TYPES: dict[SchemaAttribute.Type, Callable[[], TypeEngine[Any]]] = { + SchemaAttribute.Type.string: String, + SchemaAttribute.Type.reference: String, + SchemaAttribute.Type.boolean: Boolean, + SchemaAttribute.Type.integer: Integer, + SchemaAttribute.Type.decimal: Float, + SchemaAttribute.Type.date_time: lambda: DateTime(timezone=True), + SchemaAttribute.Type.binary: LargeBinary, +} + + +def _snake(name: str) -> str: + """Return a name in snake case, such as user_name for userName.""" + name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) + return re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_").lower() + + +def _fit(name: str) -> str: + """Shorten a name longer than PostgreSQL keeps, ending it with a hash of the full name.""" + if len(name) <= _MAX_NAME_LENGTH: + return name + digest = hashlib.sha256(name.encode()).hexdigest()[:8] + return f"{name[: _MAX_NAME_LENGTH - 9]}_{digest}" + + +def _sub_attribute(attribute: Any, name: str) -> Any: + """Return the sub-attribute of an attribute by its name, or None.""" + return next( + (sub for sub in attribute.sub_attributes or [] if sub.name == name), None + ) + + +@dataclass +class _Schema: + """A schema of a resource type, with the prefixes of the paths and of the columns of its attributes.""" + + urn: str + attributes: list[Any] + path_prefix: str + column_prefix: str + + +@dataclass +class _Forward: + """An attribute linking to resources, with the association table of each multi-valued link.""" + + owner: str + attribute: Any + targets: list[str] + keys: dict[str, str] + tables: dict[str, Table] + inverse_keys: dict[str, str] + + +class _Class: + """The namespace of a record class being built, and the attributes of its mapping.""" + + def __init__(self, name: str, table: str) -> None: + self.name = name + self.namespace: dict[str, Any] = {"__tablename__": table} + self.attributes: list[tuple[str, Callable[[], Any]]] = [] + + def add(self, key: str, value: Any, path: str) -> None: + """Add a column or a relationship to the class, and refuse a name already used.""" + if key in self.namespace: + raise ValueError( + f"{path!r} needs the name {key!r}, which {self.name!r} already uses" + ) + self.namespace[key] = value + + +class _Generator: + """Build the tables, the record classes and the mappings of the resource types of a provider.""" + + def __init__( + self, + provider: ScimProvider, + metadata: MetaData, + prefix: str, + store: Callable[[AttributeBinding], bool | TypeEngine[Any]], + inverses: Mapping[str, str], + ) -> None: + self.provider = provider + self.metadata = metadata + self.prefix = prefix + self.store = store + self.inverses = inverses + self.decisions: dict[str, bool | TypeEngine[Any]] = {} + self.resource_types = {str(rt.name): rt for rt in provider.resource_types} + self.tables = { + name: _fit(prefix + _snake(rt.endpoint or name)) + for name, rt in self.resource_types.items() + } + self.base: Any = type("Base", (DeclarativeBase,), {"metadata": metadata}) + self.classes: dict[str, Any] = {} + self.schemas = { + name: self._schemas(rt) for name, rt in self.resource_types.items() + } + self.forwards: dict[str, _Forward] = {} + self.reverses: dict[str, _Forward] = {} + + def _schemas(self, resource_type: ResourceType) -> list[_Schema]: + """Return the core schema and the extensions of a resource type.""" + model: Any = self.provider.model_for(resource_type) + schemas = [_Schema(model.__schema__, model.to_schema().attributes, "", "")] + for urn, extension in model.get_extension_models().items(): + schema = extension.to_schema() + schemas.append( + _Schema( + urn, + schema.attributes, + f"{urn}:", + f"{_snake(str(schema.name or extension.__name__))}_", + ) + ) + return schemas + + def generate(self) -> dict[str, ResourceMapping]: + """Return the mappings of the resource types, by name.""" + self._decide() + for name in self.resource_types: + self._find_forwards(name) + self._find_inverses() + built = {name: self._build(name) for name in self.resource_types} + for record_class in built.values(): + self._make(record_class) + self.base.registry.configure() + return { + name: ResourceMapping( + self.provider.model_for(self.resource_types[name]), # type: ignore[arg-type] + self.classes[name], + {path: build() for path, build in record_class.attributes}, + version=self.classes[name].meta_version, + ) + for name, record_class in built.items() + } + + def _decide(self) -> None: + """Ask store how to keep each attribute the tables could hold, and check the answers.""" + clear = [] + for name, schemas in self.schemas.items(): + model = self.provider.model_for(self.resource_types[name]) + for schema in schemas: + for attribute in schema.attributes: + urn = f"{schema.urn}:{attribute.name}" + if self._linked(attribute): + if ( + attribute.mutability != Mutability.read_only + or urn in self.inverses + ): + self._ask(model, urn, column=False) + continue + if attribute.mutability == Mutability.read_only: + continue + if attribute.type != SchemaAttribute.Type.complex: + if self._ask_column(model, urn, attribute): + clear.append(urn) + continue + if not self._ask(model, urn, column=False): + continue + for sub in attribute.sub_attributes or []: + sub_urn = f"{urn}.{sub.name}" + if ( + not sub.multi_valued + and sub.mutability != Mutability.read_only + and self._ask_column(model, sub_urn, sub) + ): + clear.append(sub_urn) + if clear: + raise ValueError( + f"{', '.join(repr(urn) for urn in clear)} are write-only: store " + "them with a column type that hashes them, such as Hashed, or " + "not at all" + ) + + def _ask_column(self, model: Any, urn: str, attribute: Any) -> bool: + """Ask store how to keep an attribute held by a column, and tell whether it is a write-only string kept in clear text.""" + if attribute.mutability == Mutability.write_only and not self._hashed( + attribute + ): + return False + decision = self._ask(model, urn, column=True) + return self._hashed(attribute) and decision is True + + def _ask(self, model: Any, urn: str, *, column: bool) -> bool | TypeEngine[Any]: + """Ask store how to keep an attribute, and refuse a column type for an attribute without its own column.""" + decision = self.store(Path(urn).resolve(model)) # type: ignore[arg-type] + if not column and not isinstance(decision, bool): + raise ValueError( + f"{urn!r} has no column of its own: store it or not, without a " + "column type" + ) + self.decisions[urn] = decision + return decision + + @staticmethod + def _hashed(attribute: Any) -> bool: + """Tell whether an attribute is a single write-only string, whose hash can be stored.""" + return bool( + attribute.mutability == Mutability.write_only + and attribute.type == SchemaAttribute.Type.string + and not attribute.multi_valued + ) + + def _linked(self, attribute: Any) -> list[str]: + """Return the served resource types an attribute links to, through the referenceTypes of its $ref.""" + if attribute.type != SchemaAttribute.Type.complex: + return [] + ref = _sub_attribute(attribute, "$ref") + if ref is None: + return [] + return [name for name in ref.reference_types or [] if name in self.tables] + + def _find_forwards(self, owner: str) -> None: + """Find the attributes of a resource type that link to resources, and add their association tables.""" + for schema in self.schemas[owner]: + for attribute in schema.attributes: + targets = self._linked(attribute) + urn = f"{schema.urn}:{attribute.name}" + if ( + not targets + or attribute.mutability == Mutability.read_only + or self.decisions[urn] is False + ): + continue + base = _snake(schema.column_prefix + attribute.name) + keys = { + target: _fit( + base if len(targets) == 1 else f"{base}_{_snake(target)}" + ) + for target in targets + } + tables = {} + if attribute.multi_valued: + for target in targets: + tables[target] = Table( + _fit( + f"{self.tables[owner]}_{base}_" + f"{self.tables[target].removeprefix(self.prefix)}" + ), + self.metadata, + Column( + "resource_id", + Uuid, + ForeignKey( + f"{self.tables[owner]}.id", ondelete="CASCADE" + ), + primary_key=True, + ), + Column( + "linked_id", + Uuid, + ForeignKey( + f"{self.tables[target]}.id", ondelete="CASCADE" + ), + primary_key=True, + ), + ) + self.forwards[urn] = _Forward( + owner, attribute, targets, keys, tables, {} + ) + + def _find_inverses(self) -> None: + """Pair each inverse attribute with the Link of the attribute it reverses, that links to its own resource type.""" + for reverse, forward_urn in self.inverses.items(): + forward = self.forwards.get(forward_urn) + if forward is None or self.decisions.get(reverse) is False: + continue + urn, _, name = reverse.rpartition(":") + for owner, schemas in self.schemas.items(): + for schema in schemas: + attribute = next( + (a for a in schema.attributes if a.name == name), None + ) + if ( + schema.urn != urn + or attribute is None + or not attribute.multi_valued + or owner not in forward.tables + or forward.owner not in self._linked(attribute) + ): + continue + forward.inverse_keys[owner] = _snake( + schema.column_prefix + attribute.name + ) + self.reverses[reverse] = forward + + def _build(self, name: str) -> _Class: + """Return the class of a resource type, with its columns and relationships, and the attributes of its mapping.""" + record_class = _Class(name, self.tables[name]) + version = mapped_column(Integer, nullable=False) + for key, value, path in [ + ("id", mapped_column(Uuid, primary_key=True, default=uuid.uuid4), "id"), + ("external_id", mapped_column(String), "externalId"), + ( + "meta_created", + mapped_column(DateTime(timezone=True), nullable=False), + "meta.created", + ), + ( + "meta_last_modified", + mapped_column(DateTime(timezone=True), nullable=False), + "meta.lastModified", + ), + ("meta_version", version, "meta.version"), + ]: + record_class.add(key, value, path) + if key != "meta_version": + record_class.attributes.append((path, self._getter(name, key))) + record_class.namespace["__mapper_args__"] = {"version_id_col": version} + + for schema in self.schemas[name]: + for attribute in schema.attributes: + path = f"{schema.path_prefix}{attribute.name}" + key = _snake(schema.column_prefix + attribute.name) + attribute_urn = f"{schema.urn}:{attribute.name}" + if self.decisions.get(attribute_urn) is False: + continue + if attribute_urn in self.forwards: + self._link(name, record_class, path, self.forwards[attribute_urn]) + elif attribute_urn in self.reverses: + self._reverse( + name, + record_class, + path, + attribute, + key, + self.reverses[attribute_urn], + ) + elif attribute.mutability == Mutability.read_only: + continue + elif attribute.type == SchemaAttribute.Type.complex: + self._complex( + name, record_class, path, attribute, key, attribute_urn + ) + elif attribute.multi_valued: + self._values( + name, record_class, path, attribute, key, attribute_urn + ) + else: + required = schema.path_prefix == "" and ( + attribute.required == Required.true + ) + mapped = self._column( + name, + record_class, + path, + attribute, + key, + attribute_urn, + nullable=not required, + unique=attribute.uniqueness + in (Uniqueness.server, Uniqueness.global_), + ) + if mapped is not None: + record_class.attributes.append((path, mapped)) + return record_class + + def _make(self, record_class: _Class) -> None: + """Create the record class from its namespace.""" + self.classes[record_class.name] = type( + record_class.name, (self.base,), record_class.namespace + ) + + def _getter(self, name: str, key: str) -> Callable[[], Any]: + """Return a function reading an attribute of a record class once it is created.""" + return lambda: getattr(self.classes[name], key) + + def _class(self, name: str) -> Callable[[], Any]: + """Return a function reading a record class once it is created.""" + return lambda: self.classes[name] + + def _join(self, name: str, column: Any) -> Callable[[], Any]: + """Return the condition joining the records of a resource type to a column of an association table.""" + return lambda: self.classes[name].id == column + + def _column( + self, + name: str, + record_class: _Class, + path: str, + attribute: Any, + key: str, + urn: str, + *, + nullable: bool = True, + unique: bool = False, + ) -> Callable[[], Any] | None: + """Add the column of a simple attribute, and return what maps it, or None when it is not stored.""" + decision = self.decisions.get(urn, False) + if decision is False: + return None + key = _fit(key) + record_class.add( + key, + mapped_column( + self._type(attribute, decision), + nullable=nullable, + unique=unique or None, + ), + path, + ) + getter = self._getter(name, key) + if attribute.mutability != Mutability.write_only: + return getter + return lambda: Attribute(getter(), readable=False) + + @staticmethod + def _type(attribute: Any, decision: bool | TypeEngine[Any]) -> TypeEngine[Any]: + """Return the column type store gave, or the type matching the attribute.""" + if isinstance(decision, TypeEngine): + return decision + return _SQL_TYPES[attribute.type]() + + def _complex( + self, + name: str, + record_class: _Class, + path: str, + attribute: Any, + key: str, + urn: str, + ) -> None: + """Add the columns of the sub-attributes of a single-valued complex attribute, or its child table if it is multi-valued.""" + if attribute.multi_valued: + self._entries(name, record_class, path, attribute, key, urn) + return + for sub in attribute.sub_attributes or []: + if sub.multi_valued or sub.mutability == Mutability.read_only: + continue + sub_path = f"{path}.{sub.name}" + mapped = self._column( + name, + record_class, + sub_path, + sub, + f"{key}_{_snake(sub.name)}", + f"{urn}.{sub.name}", + ) + if mapped is not None: + record_class.attributes.append((sub_path, mapped)) + + def _child( + self, name: str, record_class: _Class, path: str, key: str + ) -> tuple[_Class, str]: + """Start the class of the child table of an attribute, and add the relationship to it.""" + key = _fit(key) + child_name = _fit(f"{self.tables[name]}_{key}") + child = _Class(child_name, child_name) + child.add("id", mapped_column(Integer, primary_key=True), path) + child.add( + "resource_id", + mapped_column( + Uuid, + ForeignKey(f"{self.tables[name]}.id", ondelete="CASCADE"), + nullable=False, + ), + path, + ) + record_class.add( + key, + relationship( + self._class(child_name), + cascade="all, delete-orphan", + order_by=self._getter(child_name, "id"), + ), + path, + ) + return child, key + + def _entries( + self, + name: str, + record_class: _Class, + path: str, + attribute: Any, + key: str, + urn: str, + ) -> None: + """Add the child table of a multi-valued complex attribute, with a column for each stored sub-attribute.""" + child, key = self._child(name, record_class, path, key) + columns = {} + for sub in attribute.sub_attributes or []: + if sub.multi_valued or sub.mutability == Mutability.read_only: + continue + mapped = self._column( + child.name, + child, + f"{path}.{sub.name}", + sub, + _snake(sub.name), + f"{urn}.{sub.name}", + ) + if mapped is not None: + columns[sub.name] = mapped + if not columns: + del record_class.namespace[key] + return + self._make(child) + getter = self._getter(name, key) + record_class.attributes.append( + ( + path, + lambda: Many( + getter(), {sub: mapped() for sub, mapped in columns.items()} + ), + ) + ) + + def _values( + self, + name: str, + record_class: _Class, + path: str, + attribute: Any, + key: str, + urn: str, + ) -> None: + """Add the child table of a multi-valued simple attribute, with a value column.""" + if attribute.mutability == Mutability.write_only: + return + child, key = self._child(name, record_class, path, key) + child.add( + "value", + mapped_column(self._type(attribute, self.decisions[urn]), nullable=False), + path, + ) + self._make(child) + getter = self._getter(name, key) + value = self._getter(child.name, "value") + record_class.attributes.append((path, lambda: Many(getter(), value()))) + + def _link( + self, name: str, record_class: _Class, path: str, forward: _Forward + ) -> None: + """Add a relationship for each resource type an attribute links to: an association table for a multi-valued attribute, a foreign key otherwise.""" + attribute = forward.attribute + type_values = (_sub_attribute(attribute, "type") or attribute).canonical_values + typed = bool(type_values) and all( + target in type_values for target in forward.targets + ) + links = [] + for target in forward.targets: + key = forward.keys[target] + if attribute.multi_valued: + table = forward.tables[target] + value = relationship( + self._class(target), + secondary=table, + primaryjoin=self._join(name, table.c.resource_id), + secondaryjoin=self._join(target, table.c.linked_id), + back_populates=forward.inverse_keys.get(target), + ) + else: + column_key = _fit(f"{key}_id") + record_class.add( + column_key, + mapped_column( + Uuid, + ForeignKey(f"{self.tables[target]}.id", ondelete="SET NULL"), + ), + path, + ) + # A resource linking to a resource of its own type, such as a + # user managed by another user, may form a cycle, which the + # unit of work only stores with a second UPDATE. + value = relationship( + self._class(target), + foreign_keys=self._getter(name, column_key), + remote_side=self._getter(target, "id") if target == name else (), + post_update=target == name, + ) + record_class.add(key, value, path) + links.append((self._getter(name, key), target)) + record_class.attributes.append( + ( + path, + lambda: [ + Link(getter(), target, type=target if typed else None) + for getter, target in links + ], + ) + ) + + def _reverse( + self, + name: str, + record_class: _Class, + path: str, + attribute: Any, + key: str, + forward: _Forward, + ) -> None: + """Add the relationship of an inverse attribute, which reads the association table of the attribute it reverses.""" + table = forward.tables[name] + record_class.add( + key, + relationship( + self._class(forward.owner), + secondary=table, + primaryjoin=self._join(name, table.c.linked_id), + secondaryjoin=self._join(forward.owner, table.c.resource_id), + back_populates=forward.keys[name], + ), + path, + ) + type_values = (_sub_attribute(attribute, "type") or attribute).canonical_values + link_type = "direct" if "direct" in (type_values or []) else None + getter = self._getter(name, key) + record_class.attributes.append( + (path, lambda: Link(getter(), forward.owner, type=link_type)) + ) + + +def _store_all(binding: AttributeBinding) -> bool: + """Store each attribute with the column type matching it.""" + return True + + +def generate_mappings( + provider: ScimProvider, + metadata: MetaData, + *, + prefix: str = "", + store: Callable[[AttributeBinding], bool | TypeEngine[Any]] = _store_all, + inverses: Mapping[str, str] | None = None, +) -> dict[str, ResourceMapping]: + """Generate the tables and the mappings that store the resources of a provider. + + Each resource type gets a table named after its endpoint, such as + ``users``. A simple attribute gets a column, a complex attribute holding a + single value gets a column for each sub-attribute, and an extension gets + columns prefixed by the name of its schema. A multi-valued attribute gets a + child table, mapped with :class:`~scim2_sqlalchemy.Many`. A complex + attribute whose ``$ref`` links to served resource types gets a + :class:`~scim2_sqlalchemy.Link` for each of them. + + A read-only attribute is not stored, except an inverse: it reads the links + of the attribute it reverses, such as ``groups`` of a user, which reads the + ``members`` of the groups. A multi-valued sub-attribute is not stored + either, nor a write-only attribute other than a single string. A column + holds ``NOT NULL`` for a required attribute of the core schema, and + ``UNIQUE`` for an attribute unique on the server. The table and column + names longer than 63 characters are shortened. + + ``store`` decides how each attribute is kept. It receives the + :class:`~scim2_models.AttributeBinding` of the attribute, and returns: + + - :data:`True`, to store the attribute with the column type matching it; + - :data:`False`, not to store it: the published schemas leave it out; + - a column type, such as :class:`~scim2_sqlalchemy.Hashed`, to store it + in a column of this type. Only an attribute with a column of its own + takes one: a simple attribute, a sub-attribute, or the values of a + multi-valued simple attribute. + + A write-only string, such as a password, is never stored in clear text: + ``store`` gives it a column type that hashes it, or does not store it. + + Pass the mappings to the storage, and create the tables with + :meth:`MetaData.create_all ` or a + migration tool. + + :param provider: The provider of the server, which gives the resource types + and their models. + :param metadata: The :class:`~sqlalchemy.schema.MetaData` the tables are + added to. + :param prefix: A prefix of the names of the tables. + :param store: The function deciding how each attribute is kept. By + default, every attribute is stored with the column type matching it. + :param inverses: The read-only attributes reading the links of another + attribute, by URN, in addition to ``groups`` of a user reversing + ``members`` of a group. + :returns: The mappings, by resource type name. + :raises ValueError: When ``store`` keeps a write-only string in clear + text, when it gives a column type to an attribute without a column of + its own, or when two attributes need the same column name. + """ + return _Generator( + provider, + metadata, + prefix, + store, + {_GROUPS: _MEMBERS, **(inverses or {})}, + ).generate() diff --git a/src/scim2_sqlalchemy/mapping.py b/src/scim2_sqlalchemy/mapping.py index 7a7a539..ea78d70 100644 --- a/src/scim2_sqlalchemy/mapping.py +++ b/src/scim2_sqlalchemy/mapping.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from collections.abc import Iterable from collections.abc import Iterator from collections.abc import Mapping @@ -17,6 +18,7 @@ from scim2_models import Schema from scim2_models import Uniqueness from sqlalchemy import ColumnElement +from sqlalchemy import Dialect from sqlalchemy import Enum as SqlEnum from sqlalchemy import String from sqlalchemy import cast @@ -27,6 +29,7 @@ from sqlalchemy.orm import Mapper from sqlalchemy.orm import RelationshipProperty from sqlalchemy.orm import strategy_options +from sqlalchemy.types import TypeDecorator class _Storage(Enum): @@ -77,6 +80,30 @@ def __init__( self.writable = writable +class Hashed(TypeDecorator[str]): + """A column type storing the hash of a string, such as a password. + + The column stores what the hash function returns for the value written. + Map it with ``readable=False``, since the storage cannot read a hash back. + + :param hash_value: The function hashing a value. + :param length: The length of the column. + """ + + impl = String + cache_ok = True + + def __init__( + self, hash_value: Callable[[str], str], length: int | None = None + ) -> None: + super().__init__(length) + self.hash_value = hash_value + + def process_bind_param(self, value: str | None, dialect: Dialect) -> str | None: + """Hash the value before it is sent to the database.""" + return None if value is None else self.hash_value(value) + + class Many: """The entries of a multi-valued attribute, stored in a child table. @@ -705,6 +732,11 @@ def _build_column( raise ValueError( f"{path!r} must be mapped to a column or a hybrid property" ) + if attribute.readable and isinstance(getattr(expression, "type", None), Hashed): + raise ValueError( + f"{path!r} stores a hash, which the storage cannot read back: " + "map it with readable=False" + ) writable = attribute.writable descriptor = inspect(expression.class_).all_orm_descriptors[key] diff --git a/tests/test_generation.py b/tests/test_generation.py new file mode 100644 index 0000000..d8497c9 --- /dev/null +++ b/tests/test_generation.py @@ -0,0 +1,583 @@ +from typing import Annotated + +import pytest +from pydantic import Field +from scim2_client.engines.wsgi import WSGISCIMClient +from scim2_models import URN +from scim2_models import ComplexAttribute +from scim2_models import EnterpriseUser +from scim2_models import Extension +from scim2_models import Group +from scim2_models import Mutability +from scim2_models import Reference +from scim2_models import Resource +from scim2_models import ResourceType +from scim2_models import ScimProvider +from scim2_models import User +from scim2_server.applications.wsgi import WSGIApplication +from scim2_server.utils import load_default_provider +from scim2_server.utils import load_default_service_provider_config +from scim2_tester import Status +from scim2_tester import check_server +from sqlalchemy import Boolean +from sqlalchemy import DateTime +from sqlalchemy import Float +from sqlalchemy import LargeBinary +from sqlalchemy import MetaData +from sqlalchemy import String +from sqlalchemy import create_engine +from sqlalchemy import select + +from scim2_sqlalchemy import Hashed +from scim2_sqlalchemy import generate_mappings + +ENTERPRISE = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" +STAFFING = "urn:example:params:scim:schemas:extension:staffing:2.0:User" +PROJECT = "urn:example:params:scim:schemas:core:2.0:Project" +PASSWORD = "urn:ietf:params:scim:schemas:core:2.0:User:password" + + +def hash_password(password): + """Mark a password as hashed, so that a test can tell it from the clear text.""" + return f"hashed:{password}" + + +def hashing_passwords(binding): + """Store the password hashed, and the other attributes as they are.""" + return Hashed(hash_password) if binding.urn == PASSWORD else True + + +def hashing_secrets(binding): + """Store each write-only string hashed, and the other attributes as they are.""" + if binding.get_annotation(Mutability) == Mutability.write_only: + return Hashed(hash_password) + return True + + +def without_secrets(binding): + """Store every attribute but the write-only strings.""" + return binding.get_annotation(Mutability) != Mutability.write_only + + +def create_tables(database_url, metadata): + """Create the tables of metadata, after dropping those another test generated.""" + engine = create_engine(database_url) + generated = MetaData() + generated.reflect(engine, only=lambda name, _: name.startswith("generated_")) + generated.drop_all(engine) + metadata.create_all(engine) + engine.dispose() + + +def publishing(provider, mappings): + """Return a provider publishing the schemas the mappings store.""" + return ScimProvider.from_discovery( + [schema for mapping in mappings.values() for schema in mapping.schemas()], + provider.resource_types, + config=load_default_service_provider_config(), + ) + + +@pytest.fixture +def generated(database_url): + """Return the mappings generated for the default resource types, over empty tables.""" + metadata = MetaData() + mappings = generate_mappings( + load_default_provider(), + metadata, + prefix="generated_", + store=hashing_passwords, + ) + create_tables(database_url, metadata) + return mappings + + +@pytest.fixture +def storage(storage_factory, generated): + """Return a storage serving the generated mappings, with the schemas they publish.""" + return storage_factory( + mappings=generated, provider=publishing(load_default_provider(), generated) + ) + + +def test_a_user_is_stored_with_every_kind_of_attribute(storage, user_type): + """Columns, flattened sub-attributes, child tables, binary values, an extension and a Link store a whole user.""" + boss = storage.create(user_type, User[EnterpriseUser](user_name="boss")) + user = User[EnterpriseUser].model_validate( + { + "userName": "bjensen", + "name": {"givenName": "Barbara", "familyName": "Jensen"}, + "active": True, + "emails": [ + {"value": "bjensen@example.com", "type": "work", "primary": True}, + {"value": "babs@example.com", "type": "home"}, + ], + "x509Certificates": [{"value": "TUlJRA=="}], + ENTERPRISE: {"employeeNumber": "701984", "manager": {"value": boss.id}}, + } + ) + + created = storage.create(user_type, user).model_dump() + + assert created["name"] == {"givenName": "Barbara", "familyName": "Jensen"} + assert created["active"] is True + assert [email["value"] for email in created["emails"]] == [ + "bjensen@example.com", + "babs@example.com", + ] + assert created["x509Certificates"] == [{"value": "TUlJRA=="}] + assert created[ENTERPRISE]["employeeNumber"] == "701984" + assert created[ENTERPRISE]["manager"]["value"] == boss.id + + +def test_a_group_holds_users_and_groups(storage, user_type, group_type): + """Members links to both resource types, with their type, and groups of a user reads the direct groups.""" + alice = storage.create(user_type, User[EnterpriseUser](user_name="alice")) + admins = storage.create( + group_type, Group(display_name="admins", members=[{"value": alice.id}]) + ) + + staff = storage.create( + group_type, + Group( + display_name="staff", members=[{"value": admins.id}, {"value": alice.id}] + ), + ) + + assert [(m.value, m.type) for m in staff.members] == [ + (alice.id, "User"), + (admins.id, "Group"), + ] + groups = storage.get(user_type, alice.id).groups + assert sorted((g.display, g.type) for g in groups) == [ + ("admins", "direct"), + ("staff", "direct"), + ] + + +def test_the_password_is_stored_hashed( + sync_storage_factory, session, generated, user_type +): + """The column holds what the hash function returns, and the user never shows it.""" + storage = sync_storage_factory(mappings=generated) + user = storage.create( + user_type, User[EnterpriseUser](user_name="alice", password="secret") + ) + record = generated["User"].record + + assert session.scalar(select(record.password)) == "hashed:secret" + assert user.password is None + + +def test_two_users_can_manage_each_other(storage, user_type): + """A cycle of managers is stored, although each user needs the other one first.""" + alice = storage.create(user_type, User[EnterpriseUser](user_name="alice")) + bob = storage.create( + user_type, + User[EnterpriseUser].model_validate( + {"userName": "bob", ENTERPRISE: {"manager": {"value": alice.id}}} + ), + ) + alice = User[EnterpriseUser].model_validate( + {**alice.model_dump(), ENTERPRISE: {"manager": {"value": bob.id}}} + ) + + alice = storage.update(user_type, alice).model_dump() + + assert alice[ENTERPRISE]["manager"]["value"] == bob.id + bob = storage.get(user_type, bob.id).model_dump() + assert bob[ENTERPRISE]["manager"]["value"] == alice["id"] + + +def test_a_generated_server_passes_scim2_tester(sync_storage_factory, generated): + """A server on generated tables passes every check it announces.""" + provider = publishing(load_default_provider(), generated) + storage = sync_storage_factory(mappings=generated, provider=provider) + client = WSGISCIMClient( + WSGIApplication(storage, provider), + base_url="http://localhost/v2", + provider=provider, + ) + + results = check_server(client) + + failures = [ + (result.title, result.reason) + for result in results + if result.status not in (Status.SKIPPED, Status.SUCCESS) + ] + assert failures == [] + + +def test_the_tables_are_named_after_the_endpoints(): + """Each resource type gets a table, each multi-valued attribute a child or an association table.""" + metadata = MetaData() + generate_mappings( + load_default_provider(), metadata, prefix="scim_", store=without_secrets + ) + + assert sorted(metadata.tables) == [ + "scim_groups", + "scim_groups_members_groups", + "scim_groups_members_users", + "scim_users", + "scim_users_addresses", + "scim_users_emails", + "scim_users_entitlements", + "scim_users_ims", + "scim_users_phone_numbers", + "scim_users_photos", + "scim_users_roles", + "scim_users_x509_certificates", + ] + + +def test_the_columns_follow_the_attributes(): + """A column takes the snake case path of its attribute, and the type, the uniqueness and the requirement of the attribute.""" + metadata = MetaData() + generate_mappings(load_default_provider(), metadata, store=without_secrets) + users = metadata.tables["users"].c + + assert not users.user_name.nullable and users.user_name.unique + assert users.name_given_name.nullable + assert isinstance(users.active.type, Boolean) + assert isinstance(users.meta_created.type, DateTime) + assert users.meta_created.type.timezone + assert users.enterprise_user_employee_number.nullable + assert "enterprise_user_manager_id" in users + assert isinstance( + metadata.tables["users_x509_certificates"].c.value.type, LargeBinary + ) + assert not metadata.tables["groups"].c.display_name.nullable + + +def test_a_write_only_string_needs_a_decision(): + """Storing a password in clear text is never a default: the caller hashes it, or does not store it.""" + with pytest.raises(ValueError, match=f"'{PASSWORD}' are write-only"): + generate_mappings(load_default_provider(), MetaData()) + + +def test_a_write_only_string_is_never_stored_in_clear_text(): + """Store keeps the password hashed, or not at all.""" + with pytest.raises(ValueError, match=f"'{PASSWORD}' are write-only"): + generate_mappings( + load_default_provider(), MetaData(), store=lambda binding: True + ) + + +def test_a_write_only_sub_attribute_is_never_stored_in_clear_text(): + """Store is asked about a sub-attribute by its full path.""" + token = f"{PROJECT}:milestones.token" + + with pytest.raises(ValueError, match=f"'{token}' are write-only"): + generate_mappings( + project_provider(), + MetaData(), + store=lambda binding: binding.urn == token or hashing_secrets(binding), + ) + + +def test_write_only_strings_are_left_out_on_request(): + """Without a hash, the password has no column, and the published schema drops it.""" + metadata = MetaData() + mappings = generate_mappings( + load_default_provider(), metadata, store=without_secrets + ) + + (core, _) = mappings["User"].schemas() + assert "password" not in {attribute.name for attribute in core.attributes} + assert "password" not in metadata.tables["users"].c + + +def test_any_attribute_can_be_left_out(): + """A simple, a complex, a multi-valued and a linking attribute are left out of the tables and of the schemas.""" + left_out = { + "urn:ietf:params:scim:schemas:core:2.0:User:nickName", + "urn:ietf:params:scim:schemas:core:2.0:User:name", + "urn:ietf:params:scim:schemas:core:2.0:User:photos", + "urn:ietf:params:scim:schemas:core:2.0:Group:members", + } + metadata = MetaData() + + mappings = generate_mappings( + load_default_provider(), + metadata, + store=lambda binding: ( + binding.urn not in left_out and hashing_passwords(binding) + ), + ) + + users = metadata.tables["users"].c + assert not {"nick_name", "name_given_name"} & set(users.keys()) + assert "users_photos" not in metadata.tables + assert "groups_members_users" not in metadata.tables + (core, _) = mappings["User"].schemas() + names = {attribute.name for attribute in core.attributes} + assert not {"nickName", "name", "photos", "groups"} & names + + +def test_an_attribute_can_get_its_own_column_type(): + """A simple attribute and the values of a list take the column type store gives.""" + tags = f"{PROJECT}:tags" + metadata = MetaData() + + generate_mappings( + project_provider(), + metadata, + store=lambda binding: ( + String(20) if binding.urn == tags else hashing_secrets(binding) + ), + ) + + assert metadata.tables["projects_tags"].c.value.type.length == 20 + assert isinstance(metadata.tables["projects"].c.secret.type, Hashed) + + +@pytest.mark.parametrize( + "urn", + [ + "urn:ietf:params:scim:schemas:core:2.0:User:name", + "urn:ietf:params:scim:schemas:core:2.0:User:emails", + "urn:ietf:params:scim:schemas:core:2.0:Group:members", + "urn:ietf:params:scim:schemas:core:2.0:User:groups", + ], + ids=["complex", "multi-valued complex", "link", "inverse"], +) +def test_an_attribute_without_a_column_takes_no_column_type(urn): + """Only an attribute with a column of its own can be given a column type.""" + with pytest.raises(ValueError, match=f"'{urn}' has no column of its own"): + generate_mappings( + load_default_provider(), + MetaData(), + store=lambda binding: ( + String() if binding.urn == urn else hashing_passwords(binding) + ), + ) + + +def test_a_readable_attribute_cannot_be_hashed(): + """The storage would return the hash of the user name instead of the user name.""" + user_name = "urn:ietf:params:scim:schemas:core:2.0:User:userName" + + with pytest.raises(ValueError, match="'userName' stores a hash"): + generate_mappings( + load_default_provider(), + MetaData(), + store=lambda binding: ( + Hashed(hash_password) + if binding.urn == user_name + else hashing_passwords(binding) + ), + ) + + +class ProjectMember(ComplexAttribute): + """A member of a project, linking to a user.""" + + value: str | None = None + ref: Reference["User"] | None = Field( + None, serialization_alias="$ref", validation_alias="$ref" + ) + display: str | None = None + + +class Code(ComplexAttribute): + """A complex attribute with a multi-valued and a write-only sub-attribute.""" + + value: str | None = None + lines: list[str] | None = None + key: Annotated[str | None, Mutability.write_only] = None + + +class Milestone(ComplexAttribute): + """Entries with a multi-valued, a read-only and a write-only sub-attribute.""" + + label: str | None = None + steps: list[str] | None = None + reached: Annotated[bool | None, Mutability.read_only] = None + token: Annotated[str | None, Mutability.write_only] = None + + +class Archive(ComplexAttribute): + """Entries with no stored sub-attribute.""" + + items: list[str] | None = None + + +class Project(Resource): + """A resource type holding each kind of attribute the generator handles.""" + + __schema__ = URN(PROJECT) + + title: str | None = None + budget: float | None = None + secret: Annotated[str | None, Mutability.write_only] = None + pin: Annotated[int | None, Mutability.write_only] = None + score: Annotated[int | None, Mutability.read_only] = None + code: Code | None = None + tags: list[str] | None = None + codes: Annotated[list[str] | None, Mutability.write_only] = None + milestones: list[Milestone] | None = None + archives: list[Archive] | None = None + members: list[ProjectMember] | None = None + a_description_long_enough_to_go_beyond_what_postgresql_keeps_in_a_name: ( + str | None + ) = None + + +class ProjectLink(ComplexAttribute): + """A link to a project.""" + + value: str | None = None + ref: Reference["Project"] | None = Field( + None, serialization_alias="$ref", validation_alias="$ref" + ) + display: str | None = None + + +class Staffing(Extension): + """A user extension with the projects of the user, read-only.""" + + __schema__ = URN(STAFFING) + + projects: Annotated[list[ProjectLink] | None, Mutability.read_only] = None + + +def project_provider(project=Project): + """Return a provider serving users and projects.""" + return ScimProvider( + models=[User, Staffing, project], + resource_types=[ + ResourceType.from_resource(User[Staffing]), + ResourceType.from_resource(project), + ], + ) + + +def test_attributes_the_storage_cannot_hold_are_left_out(): + """A read-only attribute, a write-only number or list, and a list in a complex attribute get no column.""" + metadata = MetaData() + generate_mappings(project_provider(), metadata, store=hashing_secrets) + projects = metadata.tables["projects"].c + milestones = metadata.tables["projects_milestones"].c + + assert {"secret", "code_value", "code_key", "title"} <= set(projects.keys()) + assert not {"pin", "score", "code_lines"} & set(projects.keys()) + assert set(milestones.keys()) == {"id", "resource_id", "label", "token"} + assert isinstance(projects.budget.type, Float) + assert isinstance(metadata.tables["projects_tags"].c.value.type, String) + assert "projects_codes" not in metadata.tables + assert "projects_archives" not in metadata.tables + + +def test_write_only_sub_attributes_are_left_out_on_request(): + """Without a hash, a write-only sub-attribute gets no column either.""" + metadata = MetaData() + generate_mappings(project_provider(), metadata, store=without_secrets) + + assert "code_key" not in metadata.tables["projects"].c + assert "token" not in metadata.tables["projects_milestones"].c + + +def test_each_write_only_string_gets_its_own_decision(): + """A secret can be hashed while a key of the same resource is not stored.""" + metadata = MetaData() + generate_mappings( + project_provider(), + metadata, + store=lambda binding: ( + binding.urn != f"{PROJECT}:code.key" and hashing_secrets(binding) + ), + ) + + projects = metadata.tables["projects"].c + assert "secret" in projects + assert "code_key" not in projects + + +def test_an_inverse_without_the_attribute_it_reverses_is_left_out(): + """Without groups to serve, the groups of a user have nothing to read.""" + metadata = MetaData() + + mappings = generate_mappings( + ScimProvider(models=[User]), metadata, store=without_secrets + ) + + (core,) = mappings["User"].schemas() + assert "groups" not in {attribute.name for attribute in core.attributes} + + +def test_a_provider_without_write_only_strings_needs_no_decision(): + """The groups hold no password, so their tables need no hash function.""" + metadata = MetaData() + + mappings = generate_mappings(ScimProvider(models=[Group]), metadata) + + assert list(mappings) == ["Group"] + + +def test_a_long_name_is_shortened(): + """PostgreSQL keeps 63 characters, so a longer name ends with a hash of the full one.""" + metadata = MetaData() + generate_mappings(project_provider(), metadata, store=without_secrets) + (name,) = [ + column.name + for column in metadata.tables["projects"].c + if column.name.startswith("a_description") + ] + + assert len(name) == 63 + assert name.startswith("a_description_long_enough_to_go_beyond_what_postgresql") + + +class CollidingProject(Project): + """A project whose code_value attribute needs the column of code.value.""" + + code_value: str | None = None + + +def test_two_attributes_needing_the_same_column_are_refused(): + """code.value and codeValue both give code_value.""" + with pytest.raises(ValueError, match="needs the name 'code_value'"): + generate_mappings( + project_provider(CollidingProject), + MetaData(), + store=without_secrets, + ) + + +def test_an_inverse_reads_the_links_of_another_attribute( + database_url, storage_factory, user_type +): + """Projects of a user, declared as the inverse of members of a project, reads the projects holding the user.""" + provider = project_provider() + metadata = MetaData() + mappings = generate_mappings( + provider, + metadata, + prefix="generated_", + store=without_secrets, + inverses={f"{STAFFING}:projects": f"{PROJECT}:members"}, + ) + create_tables(database_url, metadata) + storage = storage_factory(mappings=mappings) + project_type = provider.resource_types[1] + alice = storage.create(user_type, User[Staffing](user_name="alice")) + + project = storage.create( + project_type, + Project( + title="Apollo", + tags=["moon", "nasa"], + members=[{"value": alice.id}], + a_description_long_enough_to_go_beyond_what_postgresql_keeps_in_a_name="x", + ), + ) + + assert project.tags == ["moon", "nasa"] + assert ( + project.a_description_long_enough_to_go_beyond_what_postgresql_keeps_in_a_name + == "x" + ) + projects = storage.get(user_type, alice.id)[Staffing].projects + assert [p.value for p in projects] == [project.id]