Base URL: http://<host>:8000
Interactive docs (Swagger UI): http://<host>:8000/docs
All endpoints require a Bearer JWT issued by Keycloak. Include the token in the Authorization header:
Authorization: Bearer <access_token>
There are three types of tokens used by the system:
| Client | Grant type | Who uses it | Access level |
|---|---|---|---|
smartlock-api |
password |
Human users (dashboard, CLI) | Full CRUD + user management |
smartlock-lockers |
client_credentials |
Raspberry Pi terminals | POST /auth/locker/{id}/check only |
nfc-scanner |
client_credentials |
NFC scanner module | POST /badge/scan only |
curl -X POST "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=smartlock-api" \
-d "client_secret=${CLIENT_SECRET}" \
-d "username=${USERNAME}" \
-d "password=${PASSWORD}"curl -X POST "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \
-d "grant_type=client_credentials" \
-d "client_id=smartlock-lockers" \
-d "client_secret=${LOCKER_CLIENT_SECRET}"All errors follow this structure:
{
"detail": "Human-readable error message"
}Common HTTP status codes:
401— Missing or invalid token403— Insufficient permissions (wrong role or wrong client)404— Resource not found409— Conflict (duplicate resource)500— Internal server error
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/categories/ |
Any valid JWT | List categories |
GET |
/categories/{id} |
Any valid JWT | Get category by ID |
POST |
/categories/ |
Admin | Create category |
PUT |
/categories/{id} |
Admin | Update category |
DELETE |
/categories/{id} |
Admin | Delete category |
Create / Update body:
{
"name": "Outillage" // required on create, 1-100 chars
}Response:
{
"id": 1,
"name": "Outillage",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/items/?skip=0&limit=100 |
Any valid JWT | List items |
GET |
/items/{id} |
Any valid JWT | Get item by ID |
POST |
/items/ |
Admin | Create item |
PUT |
/items/{id} |
Admin | Update item |
DELETE |
/items/{id} |
Admin | Delete item |
Create body:
{
"name": "Perceuse", // required, 1-255 chars
"reference": "P-01", // required, 1-50 chars
"description": "optional", // optional
"category_id": 1 // required, must exist
}Update body: All fields optional.
Response:
{
"id": 1,
"name": "Perceuse",
"reference": "P-01",
"description": null,
"category_id": 1,
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/lockers/?skip=0&limit=100 |
Any valid JWT | List lockers |
GET |
/lockers/{id} |
Any valid JWT | Get locker by ID |
GET |
/lockers/{id}/stock |
Any valid JWT | Get stock in a locker |
POST |
/lockers/ |
Admin | Create locker |
PUT |
/lockers/{id} |
Admin | Update locker |
DELETE |
/lockers/{id} |
Admin | Delete locker (cascades stock, permissions, logs) |
Create body:
{
"locker_type": "standard", // required, 1-50 chars
"is_active": true // optional, default true
}Update body: All fields optional.
Response:
{
"id": 1,
"locker_type": "standard",
"is_active": true,
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/stock/?skip=0&limit=100 |
Any valid JWT | List all stock entries |
GET |
/stock/{id} |
Any valid JWT | Get stock entry by ID |
POST |
/stock/ |
Admin | Create stock entry |
PUT |
/stock/{id} |
Admin | Update stock entry |
DELETE |
/stock/{id} |
Admin | Delete stock entry |
A stock entry links one item to one locker with a quantity. The pair (item_id, locker_id) must be unique.
Create body:
{
"item_id": 1, // required, must exist
"locker_id": 1, // required, must exist
"quantity": 10, // optional, default 0, >= 0
"unit_measure": "units" // optional, default "units"
}Update body: All fields optional.
Response:
{
"id": 1,
"item_id": 1,
"locker_id": 1,
"quantity": 10,
"unit_measure": "units",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}All permission endpoints require Admin auth.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/lockers/{locker_id}/permissions |
Admin | List permissions for a locker |
POST |
/lockers/{locker_id}/permissions |
Admin | Create permission |
PUT |
/lockers/permissions/{permission_id} |
Admin | Update permission |
DELETE |
/lockers/permissions/{permission_id} |
Admin | Delete permission |
Permissions can target either a role (all users with that Keycloak role) or a specific user (by Keycloak user UUID). User-specific permissions override role-based permissions.
Create body:
{
"locker_id": 1, // required, must match URL
"subject_type": "role", // "role" or "user"
"role_name": "3D", // required if subject_type is "role"
"user_id": null, // required if subject_type is "user" (Keycloak UUID)
"can_view": true, // default true
"can_open": true, // default false
"can_edit": false, // default false
"can_take": false, // default false
"can_manage": false, // default false
"valid_until": "2025-12-31T23:59:59" // optional, ISO 8601
}Response:
{
"id": 1,
"locker_id": 1,
"subject_type": "role",
"role_name": "3D",
"user_id": null,
"can_view": true,
"can_open": true,
"can_edit": false,
"can_take": false,
"can_manage": false,
"valid_until": null,
"created_at": "2025-01-15T10:30:00Z"
}| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/badge/scan |
NFC Scanner (nfc-scanner) |
Register a scanned NFC badge |
GET |
/badge/pending |
Admin | List pending (unassigned) badges |
PATCH |
/badge/{card_id}/assign |
Admin | Mark a badge as assigned |
Scan body:
{
"card_id": "AA:BB:CC:11:22" // required, 1-64 chars
}Scan response (201):
{
"success": true,
"message": "Carte enregistree, en attente d'assignation par un admin",
"card_id": "AA:BB:CC:11:22"
}Pending response:
[
{
"id": 1,
"card_id": "AA:BB:CC:11:22",
"scanned_at": "2025-01-15T10:30:00Z",
"status": "pending"
}
]| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/auth/locker/{locker_id}/check |
Locker client (smartlock-lockers) |
Check if a badge can open a locker |
Request body:
{
"card_id": "AA:BB:CC:11:22"
}Response:
{
"allowed": true,
"display_name": "Alice Dupont",
"reason": null,
"permissions": {
"can_view": true,
"can_open": true,
"can_edit": false,
"can_take": false,
"can_manage": false
}
}When denied:
{
"allowed": false,
"display_name": "Alice Dupont",
"reason": "no_permission",
"permissions": null
}Possible reason values: card_not_registered, keycloak_error, no_permission.
All log endpoints require Codir or Admin auth.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/logs/?skip=0&limit=100&locker_id=1 |
Codir or Admin | List access logs (optional locker filter) |
Response:
[
{
"id": 1,
"locker_id": 1,
"card_id": "AA:BB:CC:11:22",
"user_id": "keycloak-uuid",
"username": "Alice Dupont",
"result": "allowed",
"reason": null,
"can_open": true,
"can_view": true,
"timestamp": "2025-01-15T10:30:00Z"
}
]| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/me |
Any user | Identité du caller + rôles enrichis + permissions par armoire résolues |
Returns an enriched profile derived from the caller's JWT — used by the dashboard's session refresh to populate the global user context.
{
"id": "<keycloak sub uuid>",
"username": "<preferred_username>",
"displayName": "<given_name family_name | preferred_username | sub>",
"email": "<email or ''>",
"enabled": true,
"roles": [ /* full RoleResponse for each realm role with a backend row */ ],
"armoirePermissions": [
{ "armoire_id": 1, "level": "can_view" | "can_open" | "can_edit" }
]
}Resolution rules for armoirePermissions:
- Source: rows in
locker_permissionsmatchingrealm_access.rolesfrom the JWT. - Expired rows (
valid_untilin the past) are filtered out. - When multiple roles grant access to the same locker, the highest level wins (
can_edit > can_open > can_view). - Lockers with no matching permission are omitted; the dashboard defaults missing entries to
none.
Identity rules:
id→ JWTsub.username→ JWTpreferred_username, fallback tosub.displayName→"<given_name> <family_name>"if both present, elsepreferred_username, elsesub.- Realm roles in the JWT that have no row in the backend
rolestable are silently dropped fromroles.
All user endpoints require Admin auth. User/group creation and modification is done exclusively via the Keycloak admin interface.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/users?search=&first=0&max_results=100 |
Admin | List Keycloak users |
GET |
/groups |
Admin | List Keycloak groups |
Endpoints to assign or revoke Keycloak realm roles on users. Requires at minimum Matérialiste auth; some roles require higher privilege.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/users/{user_id}/roles/{role_name} |
Matérialiste or above | Assign a role to a user |
DELETE |
/users/{user_id}/roles/{role_name} |
Matérialiste or above | Revoke a role from a user |
Role management permission matrix:
| Target role | Minimum requester role |
|---|---|
membre, 3d |
Matérialiste, Codir, Admin |
electronique, textile, materialiste |
Codir, Admin |
codir, admin |
Admin only |
Responses: 204 No Content on success, 400 if role is unknown, 403 if insufficient privilege.
Endpoints allowing codir members to temporarily self-escalate to admin and then relinquish that privilege.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/auth/elevate |
Codir | Self-assign the admin role in Keycloak |
POST |
/auth/revoke-admin |
Admin | Remove own admin role in Keycloak |
Note: After calling
/auth/elevateor/auth/revoke-admin, the user must obtain a new token for the change to take effect (Keycloak tokens are not updated in place).
Elevate response (200):
{
"message": "Rôle admin accordé temporairement. Reconnectez-vous pour l'activer."
}Revoke-admin response (200):
{
"message": "Rôle admin révoqué. Reconnectez-vous pour l'appliquer."
}