auth 3.4.0__tar.gz → 3.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {auth-3.4.0 → auth-3.5.0}/PKG-INFO +1 -1
  2. {auth-3.4.0 → auth-3.5.0}/auth/__init__.py +3 -0
  3. {auth-3.4.0 → auth-3.5.0}/auth/database.py +2 -0
  4. auth-3.5.0/auth/schema_check.py +61 -0
  5. {auth-3.4.0 → auth-3.5.0}/auth.egg-info/PKG-INFO +1 -1
  6. {auth-3.4.0 → auth-3.5.0}/auth.egg-info/SOURCES.txt +2 -0
  7. {auth-3.4.0 → auth-3.5.0}/pyproject.toml +1 -1
  8. auth-3.5.0/tests/test_schema_check.py +134 -0
  9. {auth-3.4.0 → auth-3.5.0}/LICENSE +0 -0
  10. {auth-3.4.0 → auth-3.5.0}/README.md +0 -0
  11. {auth-3.4.0 → auth-3.5.0}/auth/api_keys.py +0 -0
  12. {auth-3.4.0 → auth-3.5.0}/auth/audit.py +0 -0
  13. {auth-3.4.0 → auth-3.5.0}/auth/circuit_breaker.py +0 -0
  14. {auth-3.4.0 → auth-3.5.0}/auth/client/__init__.py +0 -0
  15. {auth-3.4.0 → auth-3.5.0}/auth/client/_transport.py +0 -0
  16. {auth-3.4.0 → auth-3.5.0}/auth/client/base.py +0 -0
  17. {auth-3.4.0 → auth-3.5.0}/auth/client/keys.py +0 -0
  18. {auth-3.4.0 → auth-3.5.0}/auth/client/rbac.py +0 -0
  19. {auth-3.4.0 → auth-3.5.0}/auth/client/workflow.py +0 -0
  20. {auth-3.4.0 → auth-3.5.0}/auth/cmd/__init__.py +0 -0
  21. {auth-3.4.0 → auth-3.5.0}/auth/cmd/server.py +0 -0
  22. {auth-3.4.0 → auth-3.5.0}/auth/config.py +0 -0
  23. {auth-3.4.0 → auth-3.5.0}/auth/core/REST/__init__.py +0 -0
  24. {auth-3.4.0 → auth-3.5.0}/auth/core/REST/client.py +0 -0
  25. {auth-3.4.0 → auth-3.5.0}/auth/core/__init__.py +0 -0
  26. {auth-3.4.0 → auth-3.5.0}/auth/core/models/__init__.py +0 -0
  27. {auth-3.4.0 → auth-3.5.0}/auth/decorators.py +0 -0
  28. {auth-3.4.0 → auth-3.5.0}/auth/docs_page.py +0 -0
  29. {auth-3.4.0 → auth-3.5.0}/auth/encryption.py +0 -0
  30. {auth-3.4.0 → auth-3.5.0}/auth/keycheck.py +0 -0
  31. {auth-3.4.0 → auth-3.5.0}/auth/logging_config.py +0 -0
  32. {auth-3.4.0 → auth-3.5.0}/auth/main.py +0 -0
  33. {auth-3.4.0 → auth-3.5.0}/auth/models/sql.py +0 -0
  34. {auth-3.4.0 → auth-3.5.0}/auth/response_format.py +0 -0
  35. {auth-3.4.0 → auth-3.5.0}/auth/rls.py +0 -0
  36. {auth-3.4.0 → auth-3.5.0}/auth/routes/__init__.py +0 -0
  37. {auth-3.4.0 → auth-3.5.0}/auth/routes/_common.py +0 -0
  38. {auth-3.4.0 → auth-3.5.0}/auth/routes/apikeys.py +0 -0
  39. {auth-3.4.0 → auth-3.5.0}/auth/routes/membership.py +0 -0
  40. {auth-3.4.0 → auth-3.5.0}/auth/routes/permissions.py +0 -0
  41. {auth-3.4.0 → auth-3.5.0}/auth/routes/public.py +0 -0
  42. {auth-3.4.0 → auth-3.5.0}/auth/routes/roles.py +0 -0
  43. {auth-3.4.0 → auth-3.5.0}/auth/routes/tenant.py +0 -0
  44. {auth-3.4.0 → auth-3.5.0}/auth/routes/workflow.py +0 -0
  45. {auth-3.4.0 → auth-3.5.0}/auth/sanitizer.py +0 -0
  46. {auth-3.4.0 → auth-3.5.0}/auth/schema_reconcile.py +0 -0
  47. {auth-3.4.0 → auth-3.5.0}/auth/server.py +0 -0
  48. {auth-3.4.0 → auth-3.5.0}/auth/services/base.py +0 -0
  49. {auth-3.4.0 → auth-3.5.0}/auth/services/keys.py +0 -0
  50. {auth-3.4.0 → auth-3.5.0}/auth/services/queries.py +0 -0
  51. {auth-3.4.0 → auth-3.5.0}/auth/services/rbac.py +0 -0
  52. {auth-3.4.0 → auth-3.5.0}/auth/services/recovery.py +0 -0
  53. {auth-3.4.0 → auth-3.5.0}/auth/services/rotation.py +0 -0
  54. {auth-3.4.0 → auth-3.5.0}/auth/services/service.py +0 -0
  55. {auth-3.4.0 → auth-3.5.0}/auth/services/tenant.py +0 -0
  56. {auth-3.4.0 → auth-3.5.0}/auth/static_docs/__init__.py +0 -0
  57. {auth-3.4.0 → auth-3.5.0}/auth/static_docs/api_reference.md +0 -0
  58. {auth-3.4.0 → auth-3.5.0}/auth/static_docs/claude_guide.md +0 -0
  59. {auth-3.4.0 → auth-3.5.0}/auth/static_docs/landing.md +0 -0
  60. {auth-3.4.0 → auth-3.5.0}/auth/static_docs/page.html +0 -0
  61. {auth-3.4.0 → auth-3.5.0}/auth/validation.py +0 -0
  62. {auth-3.4.0 → auth-3.5.0}/auth/workflow_checker.py +0 -0
  63. {auth-3.4.0 → auth-3.5.0}/auth.egg-info/dependency_links.txt +0 -0
  64. {auth-3.4.0 → auth-3.5.0}/auth.egg-info/entry_points.txt +0 -0
  65. {auth-3.4.0 → auth-3.5.0}/auth.egg-info/requires.txt +0 -0
  66. {auth-3.4.0 → auth-3.5.0}/auth.egg-info/top_level.txt +0 -0
  67. {auth-3.4.0 → auth-3.5.0}/setup.cfg +0 -0
  68. {auth-3.4.0 → auth-3.5.0}/tests/test_api_keys.py +0 -0
  69. {auth-3.4.0 → auth-3.5.0}/tests/test_api_keys_encryption.py +0 -0
  70. {auth-3.4.0 → auth-3.5.0}/tests/test_audit_integrity.py +0 -0
  71. {auth-3.4.0 → auth-3.5.0}/tests/test_client.py +0 -0
  72. {auth-3.4.0 → auth-3.5.0}/tests/test_client_exceptions_do_not_render_payload.py +0 -0
  73. {auth-3.4.0 → auth-3.5.0}/tests/test_client_rest.py +0 -0
  74. {auth-3.4.0 → auth-3.5.0}/tests/test_cmd_server.py +0 -0
  75. {auth-3.4.0 → auth-3.5.0}/tests/test_config.py +0 -0
  76. {auth-3.4.0 → auth-3.5.0}/tests/test_correctness_hardening.py +0 -0
  77. {auth-3.4.0 → auth-3.5.0}/tests/test_database_pool_settings.py +0 -0
  78. {auth-3.4.0 → auth-3.5.0}/tests/test_database_sslmode.py +0 -0
  79. {auth-3.4.0 → auth-3.5.0}/tests/test_degraded_response_is_a_contract.py +0 -0
  80. {auth-3.4.0 → auth-3.5.0}/tests/test_docs_page.py +0 -0
  81. {auth-3.4.0 → auth-3.5.0}/tests/test_encryption.py +0 -0
  82. {auth-3.4.0 → auth-3.5.0}/tests/test_encryption_integration.py +0 -0
  83. {auth-3.4.0 → auth-3.5.0}/tests/test_flask.py +0 -0
  84. {auth-3.4.0 → auth-3.5.0}/tests/test_gunicorn_config_invariants.py +0 -0
  85. {auth-3.4.0 → auth-3.5.0}/tests/test_key_rotation.py +0 -0
  86. {auth-3.4.0 → auth-3.5.0}/tests/test_log_redaction.py +0 -0
  87. {auth-3.4.0 → auth-3.5.0}/tests/test_no_bundled_libpq.py +0 -0
  88. {auth-3.4.0 → auth-3.5.0}/tests/test_phase_a_hardening.py +0 -0
  89. {auth-3.4.0 → auth-3.5.0}/tests/test_provision_script_no_dsn_leak.py +0 -0
  90. {auth-3.4.0 → auth-3.5.0}/tests/test_reencryption.py +0 -0
  91. {auth-3.4.0 → auth-3.5.0}/tests/test_routes_errors.py +0 -0
  92. {auth-3.4.0 → auth-3.5.0}/tests/test_self_service_audit.py +0 -0
  93. {auth-3.4.0 → auth-3.5.0}/tests/test_server.py +0 -0
  94. {auth-3.4.0 → auth-3.5.0}/tests/test_service_lifecycle.py +0 -0
  95. {auth-3.4.0 → auth-3.5.0}/tests/test_settings_do_not_echo_secrets.py +0 -0
  96. {auth-3.4.0 → auth-3.5.0}/tests/test_strict_default.py +0 -0
  97. {auth-3.4.0 → auth-3.5.0}/tests/test_strict_users.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 3.4.0
3
+ Version: 3.5.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -10,6 +10,7 @@ from auth.client import ( # Import the new client
10
10
  EnhancedAuthClient,
11
11
  )
12
12
  from auth.database import SessionLocal
13
+ from auth.schema_check import SchemaMismatchError, check_schema
13
14
  from auth.services.service import AuthorizationService
14
15
 
15
16
  try:
@@ -157,5 +158,7 @@ __all__ = [
157
158
  "Client",
158
159
  "EnhancedAuthClient",
159
160
  "SessionLocal",
161
+ "SchemaMismatchError",
162
+ "check_schema",
160
163
  "__version__",
161
164
  ]
@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session, sessionmaker
15
15
  from sqlalchemy.pool import Pool
16
16
 
17
17
  from auth.config import DatabaseType, Settings, get_settings, warn_on_weak_secrets
18
+ from auth.schema_check import check_schema
18
19
  from auth.schema_reconcile import (
19
20
  GRANDFATHER_MARKER, # noqa: F401 - re-exported; tests and embedded callers import it from here
20
21
  _apply_tenant_rls,
@@ -379,6 +380,7 @@ def create_tables(raise_on_error: bool = False):
379
380
  _ensure_audit_partition_runway(engine)
380
381
  _reconcile_text_columns(engine)
381
382
  _grandfather_strict_users(engine)
383
+ check_schema(engine)
382
384
  logger.info("Tables created successfully.")
383
385
  except Exception:
384
386
  logger.exception("create_tables failed")
@@ -0,0 +1,61 @@
1
+ from typing import List, Optional, Sequence, Tuple, Union
2
+
3
+ from sqlalchemy import Table, inspect
4
+ from sqlalchemy.engine import Connection, Engine
5
+
6
+ Bind = Union[Engine, Connection]
7
+
8
+
9
+ class SchemaMismatchError(RuntimeError):
10
+ def __init__(
11
+ self, missing_tables: Sequence[str], missing_columns: Sequence[str]
12
+ ) -> None:
13
+ self.missing_tables = list(missing_tables)
14
+ self.missing_columns = list(missing_columns)
15
+ parts = []
16
+ if self.missing_tables:
17
+ parts.append("missing tables: " + ", ".join(self.missing_tables))
18
+ if self.missing_columns:
19
+ parts.append("missing columns: " + ", ".join(self.missing_columns))
20
+ super().__init__(
21
+ "the live database is behind the installed auth models ("
22
+ + "; ".join(parts)
23
+ + "). The auth changelog lists the DDL each release needs."
24
+ )
25
+
26
+
27
+ def _qualified(table: Table) -> str:
28
+ return f"{table.schema}.{table.name}" if table.schema else table.name
29
+
30
+
31
+ def find_schema_drift(bind: Bind) -> Tuple[List[str], List[str]]:
32
+ import auth.audit # noqa: F401
33
+ from auth.models.sql import Base
34
+
35
+ inspector = inspect(bind)
36
+ missing_tables: List[str] = []
37
+ missing_columns: List[str] = []
38
+ for table in sorted(Base.metadata.tables.values(), key=_qualified):
39
+ if not inspector.has_table(table.name, schema=table.schema):
40
+ missing_tables.append(_qualified(table))
41
+ continue
42
+ live = {
43
+ column["name"]
44
+ for column in inspector.get_columns(table.name, schema=table.schema)
45
+ }
46
+ missing_columns.extend(
47
+ f"{_qualified(table)}.{column.name}"
48
+ for column in table.columns
49
+ if column.name not in live
50
+ )
51
+ return missing_tables, missing_columns
52
+
53
+
54
+ def check_schema(bind: Optional[Bind] = None) -> None:
55
+ if bind is None:
56
+ from auth.database import engine
57
+
58
+ bind = engine
59
+ missing_tables, missing_columns = find_schema_drift(bind)
60
+ if missing_tables or missing_columns:
61
+ raise SchemaMismatchError(missing_tables, missing_columns)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 3.4.0
3
+ Version: 3.5.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -16,6 +16,7 @@ auth/main.py
16
16
  auth/response_format.py
17
17
  auth/rls.py
18
18
  auth/sanitizer.py
19
+ auth/schema_check.py
19
20
  auth/schema_reconcile.py
20
21
  auth/server.py
21
22
  auth/validation.py
@@ -85,6 +86,7 @@ tests/test_phase_a_hardening.py
85
86
  tests/test_provision_script_no_dsn_leak.py
86
87
  tests/test_reencryption.py
87
88
  tests/test_routes_errors.py
89
+ tests/test_schema_check.py
88
90
  tests/test_self_service_audit.py
89
91
  tests/test_server.py
90
92
  tests/test_service_lifecycle.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "auth"
7
- version = "3.4.0"
7
+ version = "3.5.0"
8
8
  description = "Authorization for humans"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,134 @@
1
+ import logging
2
+
3
+ import pytest
4
+ from sqlalchemy import create_engine, event, text
5
+
6
+ import auth
7
+ import auth.audit # noqa: F401
8
+ import auth.database
9
+ from auth.models.sql import Base
10
+ from auth.schema_check import SchemaMismatchError, check_schema, find_schema_drift
11
+
12
+ V310_TENANT_SETTINGS = (
13
+ "CREATE TABLE auth_tenant_settings ("
14
+ "id INTEGER PRIMARY KEY, creator VARCHAR(64) NOT NULL, "
15
+ "strict_users BOOLEAN NOT NULL, date_created DATETIME, modified DATETIME)"
16
+ )
17
+ V340_ADDED = [
18
+ "auth_tenant_settings.rotate_key_hash",
19
+ "auth_tenant_settings.rotate_key_issued_at",
20
+ "auth_tenant_settings.canary",
21
+ ]
22
+
23
+ pytestmark = pytest.mark.skipif(
24
+ any(t.schema for t in Base.metadata.tables.values()),
25
+ reason="SQLite drift tests build unqualified tables",
26
+ )
27
+
28
+
29
+ @pytest.fixture
30
+ def current_engine(tmp_path):
31
+ engine = create_engine(f"sqlite:///{tmp_path / 'current.sqlite3'}")
32
+ Base.metadata.create_all(engine)
33
+ yield engine
34
+ engine.dispose()
35
+
36
+
37
+ @pytest.fixture
38
+ def v310_engine(current_engine):
39
+ with current_engine.begin() as conn:
40
+ conn.execute(text("DROP TABLE auth_tenant_settings"))
41
+ conn.execute(text(V310_TENANT_SETTINGS))
42
+ return current_engine
43
+
44
+
45
+ def _statements(engine):
46
+ seen = []
47
+ event.listen(
48
+ engine,
49
+ "before_cursor_execute",
50
+ lambda conn, cursor, statement, *a: seen.append(statement),
51
+ )
52
+ return seen
53
+
54
+
55
+ def test_current_schema_passes(current_engine):
56
+ assert find_schema_drift(current_engine) == ([], [])
57
+ check_schema(current_engine)
58
+
59
+
60
+ def test_v310_tenant_settings_names_the_three_missing_columns(v310_engine):
61
+ assert find_schema_drift(v310_engine) == ([], V340_ADDED)
62
+ with pytest.raises(SchemaMismatchError) as caught:
63
+ check_schema(v310_engine)
64
+ assert caught.value.missing_columns == V340_ADDED
65
+ assert caught.value.missing_tables == []
66
+ for name in V340_ADDED:
67
+ assert name in str(caught.value)
68
+
69
+
70
+ def test_missing_table_is_named(current_engine):
71
+ with current_engine.begin() as conn:
72
+ conn.execute(text("DROP TABLE auth_tenant_settings"))
73
+ with pytest.raises(SchemaMismatchError) as caught:
74
+ check_schema(current_engine)
75
+ assert caught.value.missing_tables == ["auth_tenant_settings"]
76
+ assert caught.value.missing_columns == []
77
+ assert "missing tables: auth_tenant_settings" in str(caught.value)
78
+
79
+
80
+ def test_extra_live_columns_are_allowed(current_engine):
81
+ with current_engine.begin() as conn:
82
+ conn.execute(text("ALTER TABLE auth_tenant_settings ADD COLUMN creator_fp TEXT"))
83
+ check_schema(current_engine)
84
+
85
+
86
+ def test_check_issues_no_ddl_or_writes(v310_engine):
87
+ seen = _statements(v310_engine)
88
+ with v310_engine.begin() as conn:
89
+ conn.execute(text("ALTER TABLE auth_tenant_settings ADD COLUMN probe TEXT"))
90
+ assert any(s.startswith("ALTER TABLE") for s in seen)
91
+ seen.clear()
92
+ with pytest.raises(SchemaMismatchError):
93
+ check_schema(v310_engine)
94
+ assert seen
95
+ writes = ("CREATE", "ALTER", "DROP", "INSERT", "UPDATE", "DELETE")
96
+ assert [s for s in seen if s.lstrip().upper().startswith(writes)] == []
97
+
98
+
99
+ def test_message_does_not_carry_the_database_location(v310_engine):
100
+ with pytest.raises(SchemaMismatchError) as caught:
101
+ check_schema(v310_engine)
102
+ assert str(v310_engine.url.database) not in str(caught.value)
103
+ assert "sqlite" not in str(caught.value)
104
+
105
+
106
+ def test_create_tables_raises_on_drift_when_asked(v310_engine, monkeypatch):
107
+ monkeypatch.setattr(auth.database, "engine", v310_engine)
108
+ with pytest.raises(SchemaMismatchError) as caught:
109
+ auth.database.create_tables(raise_on_error=True)
110
+ assert caught.value.missing_columns == V340_ADDED
111
+
112
+
113
+ def test_create_tables_logs_drift_by_default(v310_engine, monkeypatch, caplog):
114
+ monkeypatch.setattr(auth.database, "engine", v310_engine)
115
+ with caplog.at_level(logging.ERROR, logger=auth.database.logger.name):
116
+ auth.database.create_tables()
117
+ logged = "\n".join(r.getMessage() + (r.exc_text or "") for r in caplog.records)
118
+ assert "create_tables failed" in logged
119
+ for name in V340_ADDED:
120
+ assert name in logged
121
+ assert "Tables created successfully." not in caplog.text
122
+
123
+
124
+ def test_create_tables_on_current_schema_reports_success(current_engine, monkeypatch, caplog):
125
+ monkeypatch.setattr(auth.database, "engine", current_engine)
126
+ with caplog.at_level(logging.INFO, logger=auth.database.logger.name):
127
+ auth.database.create_tables(raise_on_error=True)
128
+ assert "Tables created successfully." in caplog.text
129
+
130
+
131
+ def test_public_exports():
132
+ assert auth.check_schema is check_schema
133
+ assert auth.SchemaMismatchError is SchemaMismatchError
134
+ assert {"check_schema", "SchemaMismatchError"} <= set(auth.__all__)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes