istSOS4 Swagger Guide

istSOS4 · OGC SensorThings API

Swagger Guide

Try istSOS4’s authentication, roles and row-level security from the Swagger page, and read the code behind every result.

Start here

Run Swagger

Swagger UI is the interactive documentation page that istSOS4 serves for its own API. Every endpoint is listed on it, and Try it out sends a real request from your browser and shows the response, so you can test the API without curl or Postman.

  1. Start the application

    You need Git and Docker with the Compose plugin. The example environment file is already set up for testing, so change nothing on a first run.

    Terminalsh
    git clone https://github.com/KinshukSS2/istSOS4.gitcd istSOS4git checkout docs/swagger-api-documentationcp .env.testing .envdocker compose up -d --build

    Give it about a minute while the database is created and the dummy data is generated. To watch progress, run docker compose logs -f dummy_data.

  2. Open Swagger

    Go to http://localhost:8018/istsos4/v1.1/docs. You should see the istSOS4 API page, with endpoint groups such as Registration & Approval, Users and Policies.

  3. Authenticate

    Most endpoints need a token. Click the padlock button labelled Authorize at the top right and fill in the OAuth2 password form:

    • usernameadmin
    • passwordadmin, or the ISTSOS_ADMIN_PASSWORD value in your .env
    • client_id, client_secretleave both empty

    Click Authorize, then Close. The padlocks on the page now show as locked. Tokens are short-lived (five minutes by default), so if requests start returning 401 partway through, authorize again.

  4. Execute a request

    Expand an endpoint, click Try it out, fill in the parameters or the body, and click Execute. The status code, body and headers appear directly below. Always send requests this way. The Request URL link and the browser address bar can only send GET, so a PATCH or POST opened there fails with a 404 or 405 that looks like a bug and is not one.

  5. What you should see

    • 200POST /Login with the admin credentials returns a bearer access_token.
    • 401A request to /Datastreams without a token answers {"detail": "Not authenticated"}.
    • 401A signed-in viewer calling GET /Users gets {"message": "Insufficient privileges."}.

    These are the exact responses recorded in tests A2, C2 and A6. From here, the test list shows what to try next.

Find a test fast

All tests at a glance

Every test in one table. Pick a row to jump to its steps. The search box and filters in the sidebar narrow this table too.

IDMethodTest and endpointActing asExpectedResult
S0SQLEstablish ground truthNetwork / Datastream / Observation countsadmin—passed
A1POSTRegister a new applicant/Registeranonymous201passed
A2POSTAuthorize as admin/Loginadmin200passed
A3GETReview the pending queue/Usersadmin200passed
A4PATCHApprove the applicant/Users/{user_id}/policy-approvaladmin200passed
A5POSTLog in as the new user/Loginviewer200passed
A6GETProve the RBAC boundary/Usersviewer401passed
A7POSTRefresh the token/Refreshviewer200passed
A8PATCHChange the role/Users/{user_id}/roleadmin204passed
A9PATCHChange the password/Users/{user_id}/passwordeditor204passed
A10POSTLog out/Logouteditor200passed
A11PATCHReject an application, then re-apply/Users/{target_user_id}/rejectadmin anonymous200 201passed
A12DELETEDeactivate the account/Users/{user_id}admin200passed
B1GETStart the external login/auth/{provider}/loginexternal302passed
B2GETComplete the provider consent/auth/{provider}/callbackexternal202passed
B3PATCHActivate the external identity/Users/{user_id}/policy-approvaladmin external200passed
B4GETProvider availability/auth/eduid/loginexternal404passed
C1SQLThe access matrix is installedpg_policiesadmin—passed
C2GETAnonymous access is fully denied/Datastreamsanonymous401passed
C3GETA viewer sees only its own Network/Datastreamsviewer admin200passed
C4PATCHA viewer cannot write, even in its Network/Datastreams({datastream_id})viewer403passed
C5GETReference tables are not Network-scoped/Thingsviewer200passed
C6POSTAn editor creates in its own Network/Datastreamseditor201passed
C7POSTAn editor is blocked outside its Network/Datastreams · PATCH /Datastreams({id})editor403 404passed
C8GETObservation counts follow the Network scope/Observations?$count=trueviewer200passed
C9GETPolicies and commits are administrator-only/Policies · /Commitsviewer admin401 200passed
C10GETAn external identity behaves identically/Datastreams · /Observations · PATCHviewer external403 404passed
D1POSTCreate a custom user/Usersadmin custom201passed
D2POSTGrant a mixed rule/Policiesadmin201passed
D3GETPATCHProve the read/write split/Datastreamscustom403 200 404passed
D4DELETERemove the grant/Policiesadmin custom200passed
D5GETAn external identity can be custom too/auth/google/login?requested_role=customexternal custom admin202 200 201passed

The tests

Test features

All 32 tests ran against a live deployment. Open a test to see how to repeat it in Swagger, then use the tabs for the code behind it, the recorded requests and the assertions.

Setup · 1 test

Ground truth

Establish what this deployment actually contains before asserting anything about who can see it.

S0SQLNetwork / Datastream / Observation counts#

Read the Networks, their Datastream and Observation counts, and the feature flags the API is running with.

Expected
Two Networks that each own Datastreams; AUTHORIZATION=1 and NETWORK=1.
Actual
acsot: 10 datastreams / 20160 obs · psos: 10 / 20160 · AUTHORIZATION=1 NETWORK=1 ANONYMOUS_VIEWER=0 VERSIONING=1 REDIS=0

Part A · 12 tests

Local account lifecycle

Registration through deactivation for a password account: pending, approved, re-scoped, re-keyed, logged out, rejected, deactivated.

A1POST/Register#

Self-registration creates a pending account that holds the requested role and Network as a preference, not a grant.

Expected
201 — status: pending, with the new id
Actual
201 · status=pending · id=17
A2POST/Login#

Password login for the bootstrap administrator returns a signed JWT that Swagger keeps in the padlock.

Expected
200 — access_token, token_type: bearer
Actual
200 · bearer token issued for admin
A3GET/Users#

The admin user list shows the applicant as pending, with no effective role and never the password hash.

Expected
200 — role: null, status: pending, requested values stored
Actual
200 · role=null · status=pending · dataset_id=acsot · requested_role=viewer
A4PATCH/Users/{user_id}/policy-approval#

An administrator grants a role (defaulting to the requested one) and a Network scope, which must name an existing Network.

Expected
200 — granted_role: viewer, status becomes active
Actual
200 · granted_role=viewer · dataset_id=acsot · status=active
A5POST/Login#

Once active, the account logs in through the same bcrypt path and receives its own token.

Expected
200 — a token for the approved user
Actual
200 · token issued for the approved user
A6GET/Users#

A viewer asking for the user list is refused.

Expected
401 — {"message": "Insufficient privileges."}
Actual
401 · {"message": "Insufficient privileges."}
A7POST/Refresh#

Exchanges a still-valid token for a fresh one with a new expiry.

Expected
200 — a new access_token
Actual
200 · fresh access_token, accepted on the next call
A8PATCH/Users/{user_id}/role#

An administrator re-assigns an active user's role; the change applies on that user's very next request.

Expected
204 — GET /Users shows role: editor
Actual
204 · role is now editor
A9PATCH/Users/{user_id}/password#

A local user changes their own password after proving the current one.

Expected
204 — old password refused, new password accepted
Actual
204 · old password → 401 · new password → 200
A10POST/Logout#

Logout is acknowledged; the token is revoked only when Redis is enabled.

Expected
200 — {"message": "Successfully logged out"}
Actual
200 · REDIS=0: acknowledged, token not revoked until it expires
A11PATCH/Users/{target_user_id}/reject#

A rejected applicant cannot log in, but may register again under the same username.

Expected
200 — status: rejected; re-registering returns 201 and resets to pending
Actual
200 reject · login 401 · re-apply 201 · status back to pending
A12DELETE/Users/{user_id}#

Deactivation keeps the row and its audit history, and cuts off a still-valid token on its next use.

Expected
200 — login with the valid password now fails
Actual
200 · status=deleted · login with the valid password → 401

Part B · 4 tests

External (OIDC) authentication

The same pending → approve → active lifecycle for an identity that signs in through an OpenID provider and has no local password.

B1GET/auth/{provider}/login#

The login route stores the requested Network and role in the server session and redirects to the provider.

Expected
302 — redirect to the provider's authorize page
Actual
302 → provider /authorize (state + nonce set)
B2GET/auth/{provider}/callback#

The callback verifies the provider's token and creates a pending account from the verified claims.

Expected
202 — Registration submitted via google; account pending
Actual
202 · Registration submitted via google · account pending
B3PATCH/Users/{user_id}/policy-approval#

An administrator approves the pending identity through the same policy-approval endpoint used for local accounts; the next sign-in returns a token that behaves like a local one.

Expected
200 — activated with role 'viewer'; the next callback returns a token
Actual
200 approve · callback 200 + token · sees 10 psos datastreams
B4GET/auth/eduid/login#

A provider without credentials is not registered, and its routes answer 404.

Expected
404 — names the unconfigured provider
Actual
404 for an unconfigured provider (eduid)

Part C · 10 tests

Data visibility and Network scoping

What each role can read and change once authenticated, enforced by PostgreSQL row-level security rather than by the API.

C1SQLpg_policies#

Every role's access comes from a fixed set of row-level-security policies written once for the deployment.

Expected
All rbac_* policies present; RLS enabled on the scoped tables
Actual
42 rbac_* policies · RLS on 9 tables
C2GET/Datastreams#

Without a token, every entity endpoint refuses the request.

Expected
401 — {"detail": "Not authenticated"}
Actual
401 · {"detail": "Not authenticated"} on /Datastreams and /Things
C3GET/Datastreams#

Two users on the same PostgreSQL role see different rows, decided by their Network grant.

Expected
200 — exactly the Datastreams of the viewer's Network; admin sees all
Actual
viewer(acsot) sees 10 · admin sees 20
C4PATCH/Datastreams({datastream_id})#

The row is visible, but no write policy matches a viewer, so the update affects nothing and is reported as 403.

Expected
403 — Insufficient privileges to update this Datastream.
Actual
403 · Insufficient privileges to update this Datastream.
C5GET/Things#

Things, Sensors, Locations and the other reference tables are shared: any authenticated role reads all of them.

Expected
200 — @iot.count equals every Thing in the database
Actual
200 · @iot.count=5 (all Things, not Network-filtered)
C6POST/Datastreams#

A Network-scoped editor can create a Datastream that belongs to its own Network.

Expected
201 — Location header with the new id
Actual
201 · Datastream 25 created in acsot
C7POST/Datastreams · PATCH /Datastreams({id})#

Creating into another Network fails the policy check; another Network's rows look like they do not exist.

Expected
POST → 403 Insufficient privileges. · PATCH → 404 Datastream not found.
Actual
POST into other Network → 403 · PATCH other Network's row → 404
C8GET/Observations?$count=true#

Observation has no Network column; its scope flows down from its parent Datastream.

Expected
200 — @iot.count equals the viewer's Network's observations
Actual
200 · @iot.count=20160 (whole table: 40320)
C9GET/Policies · /Commits#

Neither table has a per-user or per-Network column, so both are restricted outright.

Expected
viewer 401 · admin 200 (both endpoints)
Actual
Policies 401/200 · Commits 401/200 (viewer/admin)
C10GET/Datastreams · /Observations · PATCH#

A Google-authenticated viewer and a local viewer in the same Network get the same answers.

Expected
Identical counts, 403 on own rows, 404 on other rows
Actual
identical on datastreams, observation count, 403 own, 404 other

Part D · 5 tests

The custom role

A role with no standing grant: an administrator writes per-user RLS rules, so read and write scope can differ row by row.

D1POST/Users#

The custom role starts with zero access: no standing policy matches it.

Expected
201 — the new user's GET /Datastreams returns value: []
Actual
201 · GET /Datastreams → value: []
D2POST/Policies#

One call creates one RLS policy per operation, so read and write scope can differ for the same user.

Expected
201 — <name>_datastream_select and _update created
Actual
201 · demo_custom_mixed_b7d78_datastream_select + _update created
D3GETPATCH/Datastreams#

The custom user reads both granted rows, writes only one, and cannot see anything else.

Expected
sees [A, B] · PATCH A 403 · PATCH B 200 · other id 404
Actual
sees [5, 6] · PATCH read-only 403 · granted 200 · other 404
D4DELETE/Policies#

Dropping the policies returns the custom user to zero access immediately.

Expected
200 twice — back to value: []
Actual
200, 200 · custom user back to value: []
D5GET/auth/google/login?requested_role=custom#

Requesting custom at OIDC sign-in only records the wish; activation and the policy are still the admin's.

Expected
202 → activate 200 → value: [] → policy 201 → sees the granted id
Actual
202 → activate 200 → [] → policy 201 → sees [5]

Background

Reference

OverviewWhat was tested, how, and against what

istSOS4 is an open-source server for the OGC SensorThings API, which stores sensor observations. When authentication is switched on, every request is decided by two things: who is asking, and which Network the data belongs to. A wrong decision either leaks observations or locks out a legitimate user.

This guide exercises that decision layer end to end. All 32 tests ran against a live deployment, and each records what it expected, what came back, and which lines of source produced the result. A claim about access control can therefore be checked against both the traffic and the code.

What the tests cover

  • Setup: what the deployment contains before anything is asserted.
  • Part A: the life of a local account, from registration to deactivation.
  • Part B: signing in through an external OpenID provider.
  • Part C: what each role can see and change, enforced by PostgreSQL row-level security.
  • Part D: a custom role whose access is granted row by row.

The recorded run

Result32 of 32 passed, none failed, none manual
Run24 Sep 2026, 00:48 UTC · 26.0 s
Codedocs/swagger-api-documentation at c7c96d0, plus uncommitted changes
Traced50 source files
PrerequisitesWhat to have installed before you start
  • Git, to clone the repository.
  • Docker with the Compose plugin, so that docker compose works. Every image is built from the repository; nothing is pulled except base images.
  • Free ports. The API answers on 8018 and PostgreSQL is published on 45432. Both can be changed in .env (EXTERNAL_PORT, POSTGRES_EXTERNAL_PORT).
  • A modern browser for Swagger UI.
  • Optional, for Part B: a client id and secret from Google, GitHub, Microsoft, ORCID or SWITCH edu-ID if you want to complete a real external sign-in. Without them the API reports that provider as unconfigured, which is itself tested in B4.

The admin password is whatever ISTSOS_ADMIN_PASSWORD is set to in your .env. If you are not sure what the running container uses, ask it:

Terminalsh
docker exec istsos4-api printenv ISTSOS_ADMIN_PASSWORD
SetupThe settings that matter, and how to reset

.env.testing is .env.example with the right values for testing already set. These are the ones the tests depend on:

SettingValueWhy it matters
AUTHORIZATION1Authentication, roles and row-level security are only active when this is 1.
NETWORK1Creates the Network table and Datastream.network_id. Without it there is nothing to scope by.
ANONYMOUS_VIEWER0Anonymous requests are refused (test C2).
VERSIONING1Keeps the edit history behind GET /Commits.
DUMMY_DATA1Generates the sample Networks and Datastreams that the scoping tests act on.
REDIS0With 0, POST /Logout succeeds but the token keeps working until it expires. Set 1 to revoke it for real.

Do not set AUTHORIZATION or NETWORK back to 0, or none of the features in this guide are active. .env.example itself keeps the upstream defaults (all 0), which CI and ordinary deployments rely on.

Make logout actually revoke

Set REDIS=1 in .env, then restart only the API. A logged-out token is then rejected with 401 "Token has been revoked". Tokens carry no unique id, so if you log in again within the same second as a logout you receive the same, still-revoked token. Wait a second.

Terminalsh
docker compose up -d api

Start again from a clean database

Re-running docker compose up is safe, because the dummy-data generator skips itself when data exists. To wipe everything:

Terminalsh
docker compose down -vdocker compose up -d --build

Configuration of the recorded run

AUTHORIZATION1
NETWORK1
ANONYMOUS_VIEWER0
VERSIONING1
REDIS0
AuthenticationAccounts, roles, tokens and external sign-in

The life of a local account

Nobody gets access by registering. An application is held as pending, an administrator reviews it, and only approval turns it into a working account with a role.

  1. POST /Register creates a pending account (A1).
  2. An administrator lists the queue with GET /Users (A3).
  3. PATCH /Users/{user_id}/policy-approval grants the role — the same endpoint for a local applicant or an OIDC signup (A4).
  4. The user signs in with POST /Login and receives a bearer token (A5).
  5. The token can be renewed with POST /Refresh (A7) and ended with POST /Logout (A10).

Roles

RoleWhat it can doTests
pendingNothing yet. No database role is created.A1
viewerReads only the Datastreams and Observations of its own Network. Cannot write, even there.C3 C4 C8
editorCreates and updates inside its own Network. Blocked outside it.C6 C7
customStarts with no access. An administrator grants rules per user, so read and write can differ row by row.D1 D3
adminSees every Network, and alone reads Policies and Commits.C9

Two more roles exist but aren’t exercised by a test in this guide: sensor, which inserts Observations, and obs_manager, a supervisory role over the same data that also inserts FeaturesOfInterest and updates Datastream and Location within its Network.

Tokens

Signing in returns a bearer token. Swagger’s Authorize button attaches it to every request for you. Tokens expire after five minutes by default (ACCESS_TOKEN_EXPIRE_MINUTES).

External sign-in

Google, Microsoft, GitHub, ORCID and SWITCH edu-ID are supported. A provider only registers when both its client id and secret are set, so leaving a pair empty disables just that provider. The redirect URI to register with the provider is:

Redirect URItemplate
{HOSTNAME}{SUBPATH}{VERSION}/auth/{provider}/callback

An external identity has no local password. It follows the same pending, approved, active lifecycle (Part B), and once active it behaves exactly like a local account with the same role (C10).

Implementation detailsWhere the rules live, and an index of the source

The rules are split across two layers, and the tests are written to prove which layer decides what.

The API layer
FastAPI endpoints validate input with Pydantic models, check the token and the caller’s role, and answer 401 or 403. Registration, login, approval and role changes live here, and each writes an append-only audit row in the same transaction: ADMIN_APPROVAL on approval (A4, B3), and also ROLE_CHANGED on a role change (A8) and USER_CREATED when an administrator creates a user directly (D1).
The database layer
Which rows a signed-in user can read or write is decided by PostgreSQL row-level security, not by the API. The recorded run had 42 rbac_* policies with row-level security on 9 tables (C1). That is why a viewer’s query for another Network’s row returns 404: the row does not exist for that user at all.

Source index

Every file the implementation excerpts come from, with the symbols used and the tests that exercise them. File names link to the exact commit.

FileSymbolsTests
api/app/db/audit_crud.pylog_audit_eventA1
api/app/db/oidc_user_crud.pycreate_pending_oidc_user
get_user_by_provider_sub
B2
B3
api/app/db/password_crud.pyupdate_local_passwordA9
api/app/db/role_crud.pyupdate_user_roleA8
api/app/models/approval_request.pyAdminApprovalRequestA4 B3
api/app/models/password.pyPasswordUpdateRequestA9
api/app/models/register_request.pyRestrictedRegistrationRequestA1
api/app/models/role.pyRoleUpdateRequestA8
api/app/oauth.pyauthenticate_user
create_access_token
get_current_user
create_refresh_token
Bearer scheme
A2 A5 A11
A2
A5 A6 A10 A12 C2 C10
A7
C2
api/app/oidc_providers.pyProvider registration
normalize_claims
B1 B4
B2
api/app/rbac_roles.pyDB_ROLE_BY_RBAC_ROLEC1
api/app/utils/utils.pysanitize_usernameB2
api/app/v1/api.pyCommits router mountC9
api/app/v1/endpoints/create/datastream.pycreate_datastreamC6
api/app/v1/endpoints/create/functions.pyinsert_datastream_entityC6
api/app/v1/endpoints/create/login.pylogin
refresh_token
_extract_bearer_token
logout
ttl_from_exp
A2
A7
A7
A10
A10
api/app/v1/endpoints/create/oidc_login.pyoidc_login
oidc_callback
_client_for
B1 D5
B2
B4
api/app/v1/endpoints/create/policy.pycreate_policy
create_policies
D2
D2 D5
api/app/v1/endpoints/create/register_request.pyregister_requestA1 A11
api/app/v1/endpoints/create/user.pycreate_userD1
api/app/v1/endpoints/delete/policy.pydelete_policyD4
api/app/v1/endpoints/delete/user.pydelete_userA12
api/app/v1/endpoints/exception_handlers.pyhandle_insufficient_privilegeC7
api/app/v1/endpoints/functions.pyset_roleC1 C3 C10
api/app/v1/endpoints/read/commit.pyget_commitsC9
api/app/v1/endpoints/read/datastream.pyRead router auth wiringC2
api/app/v1/endpoints/read/policy.pyget_policiesC9
api/app/v1/endpoints/read/read.pyasyncpg_stream_resultsC3
api/app/v1/endpoints/read/user.pyget_usersA3 A6
api/app/v1/endpoints/update/admin_approval.pypatch_policy_approvalA4 B3 D5
api/app/v1/endpoints/update/admin_rejection.pypatch_reject_userA11
api/app/v1/endpoints/update/datastream.pyupdate_datastreamC4 D3
api/app/v1/endpoints/update/functions.pyupdate_entity
check_id_exists
C4
C7 D3
api/app/v1/endpoints/update/password.pyupdate_passwordA9
api/app/v1/endpoints/update/role.pypatch_user_roleA8
database/migrations/004_admin_rejection.sqlAuditLog action constraintA11
database/migrations/006_session_scoped_rls_policies.sqlUser table lock-down
Static read policies
Editor write policies
Session helper functions
A6
C1 C3 C5 C8 D1
C1 C4 C6 C7
C3 C8
dummy_data/generator.pygenerate_networks
create_data
S0
S0
TroubleshootingThe problems people actually hit
Authorize answers 401
The username or password is wrong. Copy the password exactly, with no quotes and no trailing space. If you are unsure of it, read it from the container as shown under Prerequisites.
Authorize fails for a confusing reason
Something was typed into client_secret. The app does not use client_id or client_secret; leave both empty.
Requests turn into 401 halfway through
The token expired. Click Authorize again.
A PATCH, POST or DELETE gives 404 or 405 in a new tab
You opened the endpoint’s URL in the browser, which sends GET. Send it with Try it out and Execute inside Swagger.
Lists come back empty, or the page will not load
The stack may still be starting. Follow docker compose logs -f dummy_data until the generator finishes.
Logout succeeds but the token still works
REDIS is 0. See Setup to turn revocation on.
Registration says the username is taken
In the tested build, deactivating a user keeps the row and its username, so a name cannot be reused. Add a suffix such as demo_alice_2 for each run.
An external login answers 404
That provider has no client id and secret configured. Set both in .env and restart the API.
Auth features seem to be switched off
Check that AUTHORIZATION and NETWORK are both 1 in .env, then run docker compose up -d --build.
Additional notesCleaning up, provenance and links

Cleaning up after a run

Authorize as admin, then call DELETE /Users/<id> for each account you created. This deactivates the account rather than removing it: the row and its username stay, and the audit trail is kept. It keeps GET /Users tidy but does not free the username.

How this guide is made

It is generated from two files in the istSOS4 repository: catalog.py, which maps each test to the source it exercises, and checks.py, which holds the executable checks. Code excerpts are read from the working tree at build time, and a reference that no longer resolves fails the build. The page you are reading is rebuilt from that report by tools/build.py in this repository.

Links

Written by Kinshuk Sanand for Google Summer of Code 2026. Tested against istSOS4 at commit c7c96d0.