diff --git a/packages/secubox-users/schema/users.json.schema.json b/packages/secubox-users/schema/users.json.schema.json new file mode 100644 index 00000000..d16db0e7 --- /dev/null +++ b/packages/secubox-users/schema/users.json.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://secubox.in/schemas/users-v2.json", + "title": "SecuBox Users v2", + "type": "object", + "required": ["version", "users", "groups"], + "additionalProperties": false, + "properties": { + "$schema": {"type": "string"}, + "version": {"const": 2}, + "users": { + "type": "array", + "items": {"$ref": "#/definitions/user"} + }, + "groups": {"type": "array"} + }, + "definitions": { + "user": { + "type": "object", + "required": [ + "username", "role", "enabled", "must_change_password", + "services", "created" + ], + "additionalProperties": false, + "properties": { + "username": {"type": "string", "pattern": "^[a-z0-9_-]{2,32}$"}, + "email": {"type": ["string", "null"], "format": "email"}, + "role": {"enum": ["admin", "operator", "viewer"]}, + "enabled": {"type": "boolean"}, + "password_hash": {"type": ["string", "null"]}, + "must_change_password": {"type": "boolean"}, + "totp": {"oneOf": [{"type": "null"}, {"$ref": "#/definitions/totp"}]}, + "google": {"oneOf": [{"type": "null"}, {"$ref": "#/definitions/google"}]}, + "services": {"type": "array", "items": {"type": "string"}}, + "created": {"type": "string", "format": "date-time"}, + "last_login": {"type": ["string", "null"], "format": "date-time"}, + "provision_results": {"type": "object"} + } + }, + "totp": { + "type": "object", + "required": ["secret", "enabled", "enrolled_at", "backup_codes"], + "additionalProperties": false, + "properties": { + "secret": {"type": "string", "minLength": 16}, + "enabled": {"type": "boolean"}, + "enrolled_at": {"type": "string", "format": "date-time"}, + "last_step": {"type": ["integer", "null"]}, + "backup_codes": { + "type": "array", + "items": { + "type": "object", + "required": ["hash", "used_at"], + "additionalProperties": false, + "properties": { + "hash": {"type": "string"}, + "used_at": {"type": ["string", "null"], "format": "date-time"} + } + } + } + } + }, + "google": { + "type": "object", + "required": ["sub", "email", "linked_at"], + "additionalProperties": false, + "properties": { + "sub": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "linked_at": {"type": "string", "format": "date-time"} + } + } + } +} diff --git a/packages/secubox-users/tests/test_schema.py b/packages/secubox-users/tests/test_schema.py new file mode 100644 index 00000000..9a743c68 --- /dev/null +++ b/packages/secubox-users/tests/test_schema.py @@ -0,0 +1,77 @@ +"""Validate users.json v2 against the JSON Schema.""" +import json +from pathlib import Path + +import jsonschema +import pytest + +SCHEMA = json.loads( + (Path(__file__).resolve().parents[1] / "schema" / "users.json.schema.json").read_text() +) + + +def _doc(**overrides): + base = { + "version": 2, + "users": [ + { + "username": "admin", + "email": "admin@example.local", + "role": "admin", + "enabled": True, + "password_hash": None, + "must_change_password": True, + "totp": None, + "google": None, + "services": [], + "created": "2026-05-13T00:00:00+00:00", + "last_login": None, + } + ], + "groups": [], + } + base.update(overrides) + return base + + +def test_minimal_valid_doc(): + jsonschema.validate(_doc(), SCHEMA) + + +def test_version_must_be_2(): + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(_doc(version=1), SCHEMA) + + +def test_username_pattern_enforced(): + doc = _doc() + doc["users"][0]["username"] = "BadName!" + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(doc, SCHEMA) + + +def test_role_enum_enforced(): + doc = _doc() + doc["users"][0]["role"] = "superuser" + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(doc, SCHEMA) + + +def test_totp_block_shape(): + doc = _doc() + doc["users"][0]["totp"] = { + "secret": "JBSWY3DPEHPK3PXP", + "enabled": True, + "enrolled_at": "2026-05-13T00:00:00+00:00", + "last_step": None, + "backup_codes": [ + {"hash": "$argon2id$...", "used_at": None} + ], + } + jsonschema.validate(doc, SCHEMA) + + +def test_password_hash_may_be_null(): + doc = _doc() + doc["users"][0]["password_hash"] = None + jsonschema.validate(doc, SCHEMA)