Write a storage#
Use this guide to keep the SCIM resources in a database table of their own. It assumes a
ScimProvider that describes the service, such as the one of
Overview. To serve the data that an application already keeps in its own tables, follow
Serve an existing data model after this guide.
Decide what to store#
A storage can keep the resources anywhere: a SQL database through a driver or an ORM, a document
database, an LDAP directory, or a remote API. The server only calls its methods. This guide keeps
the resources in a SQLite database. The synchronous storage uses sqlite3,
and the asynchronous one uses aiosqlite. A row holds a resource:
its resource type, its identifier, the userName of a user, the other attributes as JSON, its
dates and its version. A UNIQUE constraint refuses two users with the same userName,
whatever its case:
TABLE = """
CREATE TABLE IF NOT EXISTS resources (
resource_type TEXT NOT NULL,
id TEXT NOT NULL,
user_name TEXT COLLATE NOCASE,
attributes TEXT NOT NULL,
created TEXT NOT NULL,
last_modified TEXT NOT NULL,
version INTEGER NOT NULL,
PRIMARY KEY (resource_type, id),
UNIQUE (resource_type, user_name)
)
"""
Keep the resources#
ScimStorage declares the methods that the server calls to read and
write the resources: get(),
create(),
update(),
delete() and
search(). The server validates the requests and applies
them before it calls these methods: the storage only reads and writes resources.
Subclass ScimStorage, or
AsyncScimStorage for an asynchronous server. The methods of an
asynchronous storage are coroutines, and follow the same rules.
Pass the ScimProvider to the storage. Open the connection of an asynchronous
storage on its first call, from the event loop that serves the requests:
class SQLiteStorage(ScimStorage):
"""Keep the resources in a table of a SQLite database."""
def __init__(self, connection: sqlite3.Connection, provider: ScimProvider):
self.connection = connection
self.connection.row_factory = sqlite3.Row
self.connection.execute(TABLE)
self.provider = provider
class AsyncSQLiteStorage(AsyncScimStorage):
"""Keep the resources in a table of a SQLite database, with aiosqlite."""
def __init__(self, path: str, provider: ScimProvider):
self.path = path
self.connection: aiosqlite.Connection | None = None
self.provider = provider
async def connect(self) -> aiosqlite.Connection:
"""Open the database on the first call, and create its table."""
if self.connection is None:
self.connection = await aiosqlite.connect(self.path)
self.connection.row_factory = aiosqlite.Row
await self.connection.execute(TABLE)
return self.connection
async def close(self) -> None:
"""Close the database, when the application stops."""
if self.connection is not None:
await self.connection.close()
self.connection = None
Turn a row into a resource with the model that the provider gives for its resource type. Fill its id, and its
Meta with resourceType, created, lastModified and
version. The version is the ETag of the resource, so it must be a quoted entity tag,
such as W/"3" (RFC 9110 ยง8.8.3). It must change on every write.
The example counts the writes. Leave meta.location empty: the server builds it from its own URL.
def row_to_resource(
self, resource_type: ResourceType, row: sqlite3.Row
) -> Resource[Any]:
model = self.provider.model_for(resource_type)
resource = model.model_validate_json(row["attributes"])
resource.id = row["id"]
resource.meta = Meta(
resource_type=resource_type.name,
created=row["created"],
last_modified=row["last_modified"],
version=f'W/"{row["version"]}"',
)
return resource
The complete storage passes the checks of Check a storage. Each of the following sections adds one of its methods.
Read a resource#
get() returns a resource, from its resource type and its
identifier. Raise NotFoundException when it does not exist:
def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
row = self.connection.execute(
"SELECT * FROM resources WHERE resource_type = ? AND id = ?",
(resource_type.name, resource_id),
).fetchone()
if row is None:
raise NotFoundException(
detail=f"{resource_type.name} {resource_id} not found"
)
return self.row_to_resource(resource_type, row)
async def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
connection = await self.connect()
cursor = await connection.execute(
"SELECT * FROM resources WHERE resource_type = ? AND id = ?",
(resource_type.name, resource_id),
)
row = await cursor.fetchone()
if row is None:
raise NotFoundException(
detail=f"{resource_type.name} {resource_id} not found"
)
return self.row_to_resource(resource_type, row)
Create a resource#
create() stores a new resource. Choose its identifier,
and start its version at 1:
def create(
self, resource_type: ResourceType, resource: Resource[Any]
) -> Resource[Any]:
resource_id = uuid.uuid4().hex
now = datetime.now(UTC).isoformat()
with self.writing():
self.connection.execute(
"INSERT INTO resources VALUES (?, ?, ?, ?, ?, ?, 1)",
(
resource_type.name,
resource_id,
getattr(resource, "user_name", None),
resource.model_dump_json(),
now,
now,
),
)
return self.get(resource_type, resource_id)
async def create(
self, resource_type: ResourceType, resource: Resource[Any]
) -> Resource[Any]:
resource_id = uuid.uuid4().hex
now = datetime.now(UTC).isoformat()
async with self.writing() as connection:
await connection.execute(
"INSERT INTO resources VALUES (?, ?, ?, ?, ?, ?, 1)",
(
resource_type.name,
resource_id,
getattr(resource, "user_name", None),
resource.model_dump_json(),
now,
now,
),
)
return await self.get(resource_type, resource_id)
Refuse a duplicate value#
In the default user resource, the userName of a user is unique, whatever its case. On a creation and on an update, raise
UniquenessException when another user already has the value. The UNIQUE constraint of the table refuses such a write, even when two
requests arrive at the same time. The example commits each write, and turns the refusal into the
exception:
@contextmanager
def writing(self) -> Iterator[None]:
"""Commit a write, and refuse a userName another user already has."""
try:
with self.connection:
yield
except sqlite3.IntegrityError:
raise UniquenessException from None
@asynccontextmanager
async def writing(self) -> AsyncIterator[aiosqlite.Connection]:
"""Commit a write, and refuse a userName another user already has."""
connection = await self.connect()
try:
yield connection
await connection.commit()
except sqlite3.IntegrityError:
await connection.rollback()
raise UniquenessException from None
Update a resource#
update() receives the whole new state of a resource, for
a PUT request and for a PATCH request. The server has already applied the request. Keep
meta.created, and change meta.lastModified and meta.version.
When expected_version is given, write the resource only if its stored version is still
expected_version, in the same query. Another client may change the resource between two
queries. When no row changes, raise NotFoundException if the resource is
gone, and PreconditionFailedException otherwise. The version is an
ETag such as W/"3": the example compares its value, 3, with the version column.
def update(
self,
resource_type: ResourceType,
resource: Resource[Any],
*,
expected_version: str | None = None,
) -> Resource[Any]:
query = (
"UPDATE resources SET user_name = ?, attributes = ?, last_modified = ?,"
" version = version + 1 WHERE resource_type = ? AND id = ?"
)
parameters = [
getattr(resource, "user_name", None),
resource.model_dump_json(),
datetime.now(UTC).isoformat(),
resource_type.name,
resource.id,
]
if expected_version is not None:
query += " AND version = ?"
parameters.append(expected_version.removeprefix("W/").strip('"'))
with self.writing():
cursor = self.connection.execute(query, parameters)
if cursor.rowcount == 0:
self.get(resource_type, str(resource.id))
raise PreconditionFailedException
return self.get(resource_type, str(resource.id))
async def update(
self,
resource_type: ResourceType,
resource: Resource[Any],
*,
expected_version: str | None = None,
) -> Resource[Any]:
query = (
"UPDATE resources SET user_name = ?, attributes = ?, last_modified = ?,"
" version = version + 1 WHERE resource_type = ? AND id = ?"
)
parameters = [
getattr(resource, "user_name", None),
resource.model_dump_json(),
datetime.now(UTC).isoformat(),
resource_type.name,
resource.id,
]
if expected_version is not None:
query += " AND version = ?"
parameters.append(expected_version.removeprefix("W/").strip('"'))
async with self.writing() as connection:
cursor = await connection.execute(query, parameters)
if cursor.rowcount == 0:
await self.get(resource_type, str(resource.id))
raise PreconditionFailedException
return await self.get(resource_type, str(resource.id))
Delete a resource#
delete() removes the resource, with the same condition on
expected_version in its query:
def delete(
self,
resource_type: ResourceType,
resource_id: str,
*,
expected_version: str | None = None,
) -> None:
query = "DELETE FROM resources WHERE resource_type = ? AND id = ?"
parameters = [resource_type.name, resource_id]
if expected_version is not None:
query += " AND version = ?"
parameters.append(expected_version.removeprefix("W/").strip('"'))
with self.writing():
cursor = self.connection.execute(query, parameters)
if cursor.rowcount == 0:
self.get(resource_type, resource_id)
raise PreconditionFailedException
async def delete(
self,
resource_type: ResourceType,
resource_id: str,
*,
expected_version: str | None = None,
) -> None:
query = "DELETE FROM resources WHERE resource_type = ? AND id = ?"
parameters = [resource_type.name, resource_id]
if expected_version is not None:
query += " AND version = ?"
parameters.append(expected_version.removeprefix("W/").strip('"'))
async with self.writing() as connection:
cursor = await connection.execute(query, parameters)
if cursor.rowcount == 0:
await self.get(resource_type, resource_id)
raise PreconditionFailedException
Search the resources#
search() receives the resource types to search, and a
validated SearchRequest. A search on an endpoint covers one resource type,
and a search at the root covers all of them. Filter, sort and page the resources, and return the
number of matching resources with the page:
def search(
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
) -> tuple[int, list[Resource[Any]]]:
found = []
for resource_type in resource_types:
rows = self.connection.execute(
"SELECT * FROM resources WHERE resource_type = ? ORDER BY created",
(resource_type.name,),
)
found += [self.row_to_resource(resource_type, row) for row in rows]
if search_request.filter is not None:
found = [
resource for resource in found if search_request.filter.match(resource)
]
found = search_request.sort(found)
start = (search_request.start_index or 1) - 1
stop = None if search_request.count is None else start + search_request.count
return len(found), found[start:stop]
async def search(
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
) -> tuple[int, list[Resource[Any]]]:
connection = await self.connect()
found = []
for resource_type in resource_types:
cursor = await connection.execute(
"SELECT * FROM resources WHERE resource_type = ? ORDER BY created",
(resource_type.name,),
)
found += [
self.row_to_resource(resource_type, row)
for row in await cursor.fetchall()
]
if search_request.filter is not None:
found = [
resource for resource in found if search_request.filter.match(resource)
]
found = search_request.sort(found)
start = (search_request.start_index or 1) - 1
stop = None if search_request.count is None else start + search_request.count
return len(found), found[start:stop]
Count the matching resources before paging them: totalResults counts every matching
resource, and the page holds at most count of them.
Search in a database#
The search of the example reads every resource. A database storage translates the filter of the
SearchRequest into a query instead:
SearchRequest.filterholds the parsed filter, to translate into aWHEREclause;sort_by,sort_order,start_indexandcountgive the order and the page;a filter the storage cannot translate raises
InvalidFilterException. The server then answers 400. Do not filter in Python after the query has paged the results:totalResultswould be wrong.
The server already refuses a filter or a sort that its
ServiceProviderConfig does not announce. A storage that cannot search
several resource types at once raises NotImplementedException when it
receives more than one.
Enclose each operation in a transaction#
The server calls operation() around each SCIM
operation, and around each operation of a bulk request. Return a context manager from it to
open a transaction or a savepoint. The default context does nothing.
AsyncScimStorage.operation returns an
asynchronous context manager.
Serve the storage#
Pass the storage to WSGIApplication or to
ASGIApplication, or to the handler of
Integrate a web framework:
storage = SQLiteStorage(sqlite3.connect("scim.sqlite"), provider)
app = WSGIApplication(storage, provider)
async_storage = AsyncSQLiteStorage("scim.sqlite", provider)
app = ASGIApplication(async_storage, provider)
Close the database when the application stops:
storage.connection.close()
await async_storage.close()
Writing resources explains why the storage receives the whole resource, and how the versions protect concurrent writes.