identity-plan-kit 0.1.0__py3-none-any.whl

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 (93) hide show
  1. alembic/env.py +131 -0
  2. alembic/script.py.mako +28 -0
  3. alembic/versions/20250124_000000_initial_schema.py +424 -0
  4. alembic.ini +76 -0
  5. identity_plan_kit/__init__.py +126 -0
  6. identity_plan_kit/admin/__init__.py +72 -0
  7. identity_plan_kit/admin/views.py +853 -0
  8. identity_plan_kit/auth/__init__.py +36 -0
  9. identity_plan_kit/auth/dependencies.py +139 -0
  10. identity_plan_kit/auth/domain/__init__.py +1 -0
  11. identity_plan_kit/auth/domain/entities.py +130 -0
  12. identity_plan_kit/auth/domain/exceptions.py +77 -0
  13. identity_plan_kit/auth/dto/__init__.py +15 -0
  14. identity_plan_kit/auth/dto/requests.py +44 -0
  15. identity_plan_kit/auth/dto/responses.py +147 -0
  16. identity_plan_kit/auth/handlers/__init__.py +5 -0
  17. identity_plan_kit/auth/handlers/oauth_routes.py +394 -0
  18. identity_plan_kit/auth/models/__init__.py +11 -0
  19. identity_plan_kit/auth/models/refresh_token.py +93 -0
  20. identity_plan_kit/auth/models/user.py +66 -0
  21. identity_plan_kit/auth/models/user_provider.py +53 -0
  22. identity_plan_kit/auth/repositories/__init__.py +9 -0
  23. identity_plan_kit/auth/repositories/token_repo.py +201 -0
  24. identity_plan_kit/auth/repositories/user_repo.py +398 -0
  25. identity_plan_kit/auth/services/__init__.py +9 -0
  26. identity_plan_kit/auth/services/auth_service.py +557 -0
  27. identity_plan_kit/auth/services/oauth_service.py +355 -0
  28. identity_plan_kit/auth/uow.py +59 -0
  29. identity_plan_kit/cli.py +384 -0
  30. identity_plan_kit/config.py +542 -0
  31. identity_plan_kit/kit.py +574 -0
  32. identity_plan_kit/migrations.py +249 -0
  33. identity_plan_kit/plans/__init__.py +41 -0
  34. identity_plan_kit/plans/dependencies.py +152 -0
  35. identity_plan_kit/plans/domain/__init__.py +1 -0
  36. identity_plan_kit/plans/domain/entities.py +136 -0
  37. identity_plan_kit/plans/domain/exceptions.py +116 -0
  38. identity_plan_kit/plans/dto/__init__.py +5 -0
  39. identity_plan_kit/plans/dto/usage.py +60 -0
  40. identity_plan_kit/plans/models/__init__.py +17 -0
  41. identity_plan_kit/plans/models/feature.py +31 -0
  42. identity_plan_kit/plans/models/feature_usage.py +72 -0
  43. identity_plan_kit/plans/models/plan.py +48 -0
  44. identity_plan_kit/plans/models/plan_limit.py +61 -0
  45. identity_plan_kit/plans/models/plan_permission.py +49 -0
  46. identity_plan_kit/plans/models/user_plan.py +86 -0
  47. identity_plan_kit/plans/repositories/__init__.py +6 -0
  48. identity_plan_kit/plans/repositories/plan_repo.py +439 -0
  49. identity_plan_kit/plans/repositories/usage_repo.py +299 -0
  50. identity_plan_kit/plans/services/__init__.py +5 -0
  51. identity_plan_kit/plans/services/plan_service.py +570 -0
  52. identity_plan_kit/plans/uow.py +47 -0
  53. identity_plan_kit/rbac/__init__.py +23 -0
  54. identity_plan_kit/rbac/cache/__init__.py +5 -0
  55. identity_plan_kit/rbac/cache/permission_cache.py +392 -0
  56. identity_plan_kit/rbac/dependencies.py +127 -0
  57. identity_plan_kit/rbac/domain/__init__.py +1 -0
  58. identity_plan_kit/rbac/domain/entities.py +63 -0
  59. identity_plan_kit/rbac/domain/exceptions.py +56 -0
  60. identity_plan_kit/rbac/models/__init__.py +11 -0
  61. identity_plan_kit/rbac/models/permission.py +34 -0
  62. identity_plan_kit/rbac/models/role.py +42 -0
  63. identity_plan_kit/rbac/models/role_permission.py +49 -0
  64. identity_plan_kit/rbac/repositories/__init__.py +5 -0
  65. identity_plan_kit/rbac/repositories/rbac_repo.py +206 -0
  66. identity_plan_kit/rbac/services/__init__.py +5 -0
  67. identity_plan_kit/rbac/services/rbac_service.py +231 -0
  68. identity_plan_kit/rbac/uow.py +43 -0
  69. identity_plan_kit/shared/__init__.py +157 -0
  70. identity_plan_kit/shared/audit.py +404 -0
  71. identity_plan_kit/shared/circuit_breaker.py +251 -0
  72. identity_plan_kit/shared/cleanup_scheduler.py +337 -0
  73. identity_plan_kit/shared/database.py +396 -0
  74. identity_plan_kit/shared/exception_handlers.py +416 -0
  75. identity_plan_kit/shared/exceptions.py +82 -0
  76. identity_plan_kit/shared/graceful_shutdown.py +327 -0
  77. identity_plan_kit/shared/health.py +257 -0
  78. identity_plan_kit/shared/http_utils.py +63 -0
  79. identity_plan_kit/shared/lockout.py +354 -0
  80. identity_plan_kit/shared/logging.py +123 -0
  81. identity_plan_kit/shared/metrics.py +420 -0
  82. identity_plan_kit/shared/models.py +100 -0
  83. identity_plan_kit/shared/rate_limiter.py +130 -0
  84. identity_plan_kit/shared/request_id.py +121 -0
  85. identity_plan_kit/shared/schemas.py +127 -0
  86. identity_plan_kit/shared/security.py +251 -0
  87. identity_plan_kit/shared/state_store.py +574 -0
  88. identity_plan_kit/shared/uow.py +142 -0
  89. identity_plan_kit/shared/uuid7.py +38 -0
  90. identity_plan_kit-0.1.0.dist-info/METADATA +514 -0
  91. identity_plan_kit-0.1.0.dist-info/RECORD +93 -0
  92. identity_plan_kit-0.1.0.dist-info/WHEEL +4 -0
  93. identity_plan_kit-0.1.0.dist-info/entry_points.txt +2 -0
alembic/env.py ADDED
@@ -0,0 +1,131 @@
1
+ """Alembic environment configuration for async migrations."""
2
+
3
+ import asyncio
4
+ import os
5
+ from logging.config import fileConfig
6
+
7
+ from alembic import context
8
+ from sqlalchemy import pool
9
+ from sqlalchemy.engine import Connection
10
+ from sqlalchemy.ext.asyncio import async_engine_from_config
11
+
12
+ # Import all models to ensure they're registered with Base.metadata
13
+ from identity_plan_kit.shared.database import Base
14
+
15
+ # Import all model modules to register them
16
+ from identity_plan_kit.auth.models import user, user_provider, refresh_token # noqa: F401
17
+ from identity_plan_kit.rbac.models import role, permission, role_permission # noqa: F401
18
+ from identity_plan_kit.plans.models import ( # noqa: F401
19
+ plan,
20
+ feature,
21
+ plan_limit,
22
+ user_plan,
23
+ feature_usage,
24
+ plan_permission,
25
+ )
26
+
27
+ # Alembic Config object
28
+ config = context.config
29
+
30
+ # Interpret the config file for Python logging
31
+ if config.config_file_name is not None:
32
+ fileConfig(config.config_file_name)
33
+
34
+ # Target metadata for autogenerate support
35
+ target_metadata = Base.metadata
36
+
37
+
38
+ def get_url() -> str:
39
+ """Get database URL from environment or config.
40
+
41
+ For migrations, we need a sync driver (psycopg2), not async (asyncpg).
42
+ The IPK_DATABASE_URL uses asyncpg, so we convert it.
43
+ """
44
+ url = os.environ.get("IPK_DATABASE_URL", "")
45
+
46
+ if not url:
47
+ # Fall back to alembic.ini value
48
+ url = config.get_main_option("sqlalchemy.url", "")
49
+
50
+ # Convert async URL to sync for migrations
51
+ # postgresql+asyncpg://... -> postgresql://...
52
+ if "+asyncpg" in url:
53
+ url = url.replace("+asyncpg", "")
54
+
55
+ return url
56
+
57
+
58
+ def run_migrations_offline() -> None:
59
+ """Run migrations in 'offline' mode.
60
+
61
+ This configures the context with just a URL and not an Engine,
62
+ though an Engine is acceptable here as well. By skipping the Engine
63
+ creation we don't even need a DBAPI to be available.
64
+
65
+ Calls to context.execute() here emit the given string to the
66
+ script output.
67
+ """
68
+ url = get_url()
69
+ context.configure(
70
+ url=url,
71
+ target_metadata=target_metadata,
72
+ literal_binds=True,
73
+ dialect_opts={"paramstyle": "named"},
74
+ compare_type=True,
75
+ compare_server_default=True,
76
+ )
77
+
78
+ with context.begin_transaction():
79
+ context.run_migrations()
80
+
81
+
82
+ def do_run_migrations(connection: Connection) -> None:
83
+ """Run migrations with connection."""
84
+ context.configure(
85
+ connection=connection,
86
+ target_metadata=target_metadata,
87
+ compare_type=True,
88
+ compare_server_default=True,
89
+ )
90
+
91
+ with context.begin_transaction():
92
+ context.run_migrations()
93
+
94
+
95
+ async def run_async_migrations() -> None:
96
+ """Run migrations in async mode.
97
+
98
+ Creates an async engine and runs migrations within a connection.
99
+ """
100
+ # Get sync URL for alembic (it handles the connection itself)
101
+ url = get_url()
102
+
103
+ configuration = config.get_section(config.config_ini_section, {})
104
+ configuration["sqlalchemy.url"] = url
105
+
106
+ # Use sync engine for migrations (Alembic doesn't fully support async)
107
+ from sqlalchemy import create_engine
108
+
109
+ connectable = create_engine(
110
+ url,
111
+ poolclass=pool.NullPool,
112
+ )
113
+
114
+ with connectable.connect() as connection:
115
+ do_run_migrations(connection)
116
+
117
+ connectable.dispose()
118
+
119
+
120
+ def run_migrations_online() -> None:
121
+ """Run migrations in 'online' mode.
122
+
123
+ Creates a connection and runs migrations.
124
+ """
125
+ asyncio.run(run_async_migrations())
126
+
127
+
128
+ if context.is_offline_mode():
129
+ run_migrations_offline()
130
+ else:
131
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,28 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade database schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade database schema."""
28
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,424 @@
1
+ """Initial schema for IdentityPlanKit.
2
+
3
+ Revision ID: 001_initial
4
+ Revises:
5
+ Create Date: 2025-01-24 00:00:00.000000
6
+
7
+ This migration creates all core tables for:
8
+ - Authentication (users, providers, refresh_tokens)
9
+ - RBAC (roles, permissions, role_permissions)
10
+ - Plans (plans, features, limits, user_plans, usage)
11
+
12
+ Rollback Plan:
13
+ - This migration drops all tables in reverse order
14
+ - WARNING: Rollback will DELETE ALL DATA
15
+ - Before rollback in production, ensure data backup exists
16
+ """
17
+ from typing import Sequence, Union
18
+
19
+ from alembic import op
20
+ import sqlalchemy as sa
21
+ from sqlalchemy.dialects import postgresql
22
+ from uuid_utils import uuid7
23
+
24
+ # revision identifiers, used by Alembic.
25
+ revision: str = "001_initial"
26
+ down_revision: Union[str, None] = None
27
+ branch_labels: Union[str, Sequence[str], None] = None
28
+ depends_on: Union[str, Sequence[str], None] = None
29
+
30
+
31
+ def upgrade() -> None:
32
+ """Create all initial tables."""
33
+
34
+ # =============================================
35
+ # CORE TABLES
36
+ # =============================================
37
+
38
+ # Roles table
39
+ op.create_table(
40
+ "roles",
41
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
42
+ sa.Column("code", sa.String(255), nullable=False),
43
+ sa.Column("name", sa.String(255), nullable=False),
44
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_roles")),
45
+ sa.UniqueConstraint("code", name=op.f("uq_roles_code")),
46
+ )
47
+ op.create_index(op.f("ix_roles_code"), "roles", ["code"], unique=True)
48
+
49
+ # Users table
50
+ op.create_table(
51
+ "users",
52
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
53
+ sa.Column("email", sa.String(255), nullable=False),
54
+ sa.Column("role_id", postgresql.UUID(as_uuid=True), nullable=False),
55
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
56
+ sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"),
57
+ sa.Column(
58
+ "created_at",
59
+ sa.DateTime(timezone=True),
60
+ nullable=False,
61
+ server_default=sa.text("now()"),
62
+ ),
63
+ sa.Column(
64
+ "updated_at",
65
+ sa.DateTime(timezone=True),
66
+ nullable=False,
67
+ server_default=sa.text("now()"),
68
+ ),
69
+ sa.ForeignKeyConstraint(
70
+ ["role_id"],
71
+ ["roles.id"],
72
+ name=op.f("fk_users_role_id_roles"),
73
+ ),
74
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
75
+ sa.UniqueConstraint("email", name=op.f("uq_users_email")),
76
+ )
77
+ op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
78
+
79
+ # User providers table (OAuth)
80
+ op.create_table(
81
+ "user_providers",
82
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
83
+ sa.Column("code", sa.String(255), nullable=False),
84
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
85
+ sa.Column("external_user_id", sa.Text(), nullable=False),
86
+ sa.ForeignKeyConstraint(
87
+ ["user_id"],
88
+ ["users.id"],
89
+ name=op.f("fk_user_providers_user_id_users"),
90
+ ondelete="CASCADE",
91
+ ),
92
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_user_providers")),
93
+ )
94
+ op.create_index(
95
+ op.f("ix_user_providers_external_id"),
96
+ "user_providers",
97
+ ["external_user_id"],
98
+ unique=False,
99
+ )
100
+ op.create_index(
101
+ "ix_user_providers_unique",
102
+ "user_providers",
103
+ ["code", "external_user_id"],
104
+ unique=True,
105
+ )
106
+
107
+ # Refresh tokens table
108
+ op.create_table(
109
+ "refresh_tokens",
110
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
111
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
112
+ sa.Column("token_hash", sa.String(255), nullable=False),
113
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
114
+ sa.Column(
115
+ "created_at",
116
+ sa.DateTime(timezone=True),
117
+ nullable=False,
118
+ server_default=sa.text("now()"),
119
+ ),
120
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
121
+ sa.Column("user_agent", sa.Text(), nullable=True),
122
+ sa.Column("ip_address", sa.String(45), nullable=True),
123
+ sa.ForeignKeyConstraint(
124
+ ["user_id"],
125
+ ["users.id"],
126
+ name=op.f("fk_refresh_tokens_user_id_users"),
127
+ ondelete="CASCADE",
128
+ ),
129
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_refresh_tokens")),
130
+ )
131
+ op.create_index(
132
+ op.f("ix_refresh_tokens_token_hash"),
133
+ "refresh_tokens",
134
+ ["token_hash"],
135
+ unique=False,
136
+ )
137
+ op.create_index(
138
+ op.f("ix_refresh_tokens_user_id"),
139
+ "refresh_tokens",
140
+ ["user_id"],
141
+ unique=False,
142
+ )
143
+ # P2 FIX: Partial index for active token lookups (revoked_at IS NULL)
144
+ # This optimizes the common query pattern of looking up non-revoked tokens
145
+ # by hash, which happens on every authenticated request validation.
146
+ op.execute(
147
+ """
148
+ CREATE INDEX ix_refresh_tokens_hash_active
149
+ ON refresh_tokens (token_hash)
150
+ WHERE revoked_at IS NULL
151
+ """
152
+ )
153
+ # Index for token cleanup queries (finding expired tokens)
154
+ op.create_index(
155
+ "ix_refresh_tokens_expires_at",
156
+ "refresh_tokens",
157
+ ["expires_at"],
158
+ unique=False,
159
+ )
160
+ # Composite index for cleanup queries filtering by expiry and revocation
161
+ op.create_index(
162
+ "ix_refresh_tokens_cleanup",
163
+ "refresh_tokens",
164
+ ["expires_at", "revoked_at"],
165
+ unique=False,
166
+ )
167
+
168
+ # =============================================
169
+ # RBAC TABLES
170
+ # =============================================
171
+
172
+ # Permissions table
173
+ op.create_table(
174
+ "permissions",
175
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
176
+ sa.Column("code", sa.String(255), nullable=False),
177
+ sa.Column("type", sa.String(255), nullable=False),
178
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_permissions")),
179
+ sa.UniqueConstraint("code", name=op.f("uq_permissions_code")),
180
+ sa.CheckConstraint("type IN ('role', 'plan')", name="ck_permissions_type"),
181
+ )
182
+
183
+ # Role permissions junction table
184
+ op.create_table(
185
+ "role_permissions",
186
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
187
+ sa.Column("role_id", postgresql.UUID(as_uuid=True), nullable=False),
188
+ sa.Column("permission_id", postgresql.UUID(as_uuid=True), nullable=False),
189
+ sa.ForeignKeyConstraint(
190
+ ["role_id"],
191
+ ["roles.id"],
192
+ name=op.f("fk_role_permissions_role_id_roles"),
193
+ ),
194
+ sa.ForeignKeyConstraint(
195
+ ["permission_id"],
196
+ ["permissions.id"],
197
+ name=op.f("fk_role_permissions_permission_id_permissions"),
198
+ ),
199
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_role_permissions")),
200
+ sa.UniqueConstraint(
201
+ "role_id", "permission_id", name="uq_role_permissions_role_permission"
202
+ ),
203
+ )
204
+ op.create_index(
205
+ op.f("ix_role_permissions_role_id"),
206
+ "role_permissions",
207
+ ["role_id"],
208
+ unique=False,
209
+ )
210
+
211
+ # =============================================
212
+ # PLANS & FEATURES TABLES
213
+ # =============================================
214
+
215
+ # Plans table
216
+ op.create_table(
217
+ "plans",
218
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
219
+ sa.Column("code", sa.String(255), nullable=False),
220
+ sa.Column("name", sa.String(255), nullable=False),
221
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_plans")),
222
+ sa.UniqueConstraint("code", name=op.f("uq_plans_code")),
223
+ )
224
+
225
+ # Features table
226
+ op.create_table(
227
+ "features",
228
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
229
+ sa.Column("code", sa.String(255), nullable=False),
230
+ sa.Column("name", sa.String(255), nullable=False),
231
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_features")),
232
+ sa.UniqueConstraint("code", name=op.f("uq_features_code")),
233
+ )
234
+
235
+ # Plan limits table
236
+ op.create_table(
237
+ "plan_limits",
238
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
239
+ sa.Column("plan_id", postgresql.UUID(as_uuid=True), nullable=False),
240
+ sa.Column("feature_id", postgresql.UUID(as_uuid=True), nullable=False),
241
+ sa.Column("feature_limit", sa.BigInteger(), nullable=False),
242
+ sa.Column("period", sa.String(255), nullable=True),
243
+ sa.ForeignKeyConstraint(
244
+ ["plan_id"],
245
+ ["plans.id"],
246
+ name=op.f("fk_plan_limits_plan_id_plans"),
247
+ ),
248
+ sa.ForeignKeyConstraint(
249
+ ["feature_id"],
250
+ ["features.id"],
251
+ name=op.f("fk_plan_limits_feature_id_features"),
252
+ ),
253
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_plan_limits")),
254
+ sa.CheckConstraint(
255
+ "period IS NULL OR period IN ('daily', 'monthly', 'lifetime')",
256
+ name="ck_plan_limits_period",
257
+ ),
258
+ )
259
+ op.create_index(
260
+ op.f("ix_plan_limits_plan_id"),
261
+ "plan_limits",
262
+ ["plan_id"],
263
+ unique=False,
264
+ )
265
+
266
+ # Plan permissions table
267
+ op.create_table(
268
+ "plan_permissions",
269
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
270
+ sa.Column("plan_id", postgresql.UUID(as_uuid=True), nullable=False),
271
+ sa.Column("permission_id", postgresql.UUID(as_uuid=True), nullable=False),
272
+ sa.ForeignKeyConstraint(
273
+ ["plan_id"],
274
+ ["plans.id"],
275
+ name=op.f("fk_plan_permissions_plan_id_plans"),
276
+ ),
277
+ sa.ForeignKeyConstraint(
278
+ ["permission_id"],
279
+ ["permissions.id"],
280
+ name=op.f("fk_plan_permissions_permission_id_permissions"),
281
+ ),
282
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_plan_permissions")),
283
+ sa.UniqueConstraint(
284
+ "plan_id", "permission_id", name="uq_plan_permissions_plan_permission"
285
+ ),
286
+ )
287
+ op.create_index(
288
+ op.f("ix_plan_permissions_plan_id"),
289
+ "plan_permissions",
290
+ ["plan_id"],
291
+ unique=False,
292
+ )
293
+
294
+ # User plans table
295
+ op.create_table(
296
+ "user_plans",
297
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
298
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
299
+ sa.Column("plan_id", postgresql.UUID(as_uuid=True), nullable=False),
300
+ sa.Column("started_at", sa.Date(), nullable=False),
301
+ sa.Column("ends_at", sa.Date(), nullable=False),
302
+ sa.Column("custom_limits", postgresql.JSONB(), nullable=True),
303
+ sa.Column("is_cancelled", sa.Boolean(), nullable=False, server_default="false"),
304
+ sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
305
+ sa.ForeignKeyConstraint(
306
+ ["user_id"],
307
+ ["users.id"],
308
+ name=op.f("fk_user_plans_user_id_users"),
309
+ ),
310
+ sa.ForeignKeyConstraint(
311
+ ["plan_id"],
312
+ ["plans.id"],
313
+ name=op.f("fk_user_plans_plan_id_plans"),
314
+ ),
315
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_user_plans")),
316
+ )
317
+ op.create_index(
318
+ op.f("ix_user_plans_user_id"),
319
+ "user_plans",
320
+ ["user_id"],
321
+ unique=False,
322
+ )
323
+ # P1 FIX: Composite index for active plan lookups (user_id + date range + not cancelled)
324
+ op.create_index(
325
+ "ix_user_plans_active_lookup",
326
+ "user_plans",
327
+ ["user_id", "started_at", "ends_at"],
328
+ unique=False,
329
+ )
330
+ # Index for finding active (non-cancelled) plans
331
+ op.execute(
332
+ """
333
+ CREATE INDEX ix_user_plans_active_not_cancelled
334
+ ON user_plans (user_id, started_at, ends_at)
335
+ WHERE is_cancelled = false
336
+ """
337
+ )
338
+
339
+ # Feature usage table
340
+ op.create_table(
341
+ "feature_usage",
342
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
343
+ sa.Column("user_plan_id", postgresql.UUID(as_uuid=True), nullable=False),
344
+ sa.Column("feature_id", postgresql.UUID(as_uuid=True), nullable=False),
345
+ sa.Column("feature_usage", sa.BigInteger(), nullable=False, server_default="0"),
346
+ sa.Column("start_period", sa.Date(), nullable=False),
347
+ sa.Column("end_period", sa.Date(), nullable=False),
348
+ sa.ForeignKeyConstraint(
349
+ ["user_plan_id"],
350
+ ["user_plans.id"],
351
+ name=op.f("fk_feature_usage_user_plan_id_user_plans"),
352
+ ondelete="CASCADE",
353
+ ),
354
+ sa.ForeignKeyConstraint(
355
+ ["feature_id"],
356
+ ["features.id"],
357
+ name=op.f("fk_feature_usage_feature_id_features"),
358
+ ondelete="CASCADE",
359
+ ),
360
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_feature_usage")),
361
+ # P0 FIX: Unique constraint required for atomic upsert in usage tracking
362
+ sa.UniqueConstraint(
363
+ "user_plan_id", "feature_id", "start_period",
364
+ name="uq_usage_plan_feature_period",
365
+ ),
366
+ )
367
+ # Composite index for period-based queries
368
+ op.create_index(
369
+ "ix_feature_usage_period_lookup",
370
+ "feature_usage",
371
+ ["user_plan_id", "feature_id", "start_period", "end_period"],
372
+ unique=False,
373
+ )
374
+
375
+ # =============================================
376
+ # SEED DATA
377
+ # =============================================
378
+
379
+ # Generate UUID7s for seed data
380
+ admin_role_id = str(uuid7())
381
+ user_role_id = str(uuid7())
382
+ free_plan_id = str(uuid7())
383
+ pro_plan_id = str(uuid7())
384
+
385
+ # Insert default roles
386
+ op.execute(
387
+ f"""
388
+ INSERT INTO roles (id, code, name) VALUES
389
+ ('{admin_role_id}', 'admin', 'Administrator'),
390
+ ('{user_role_id}', 'user', 'User')
391
+ """
392
+ )
393
+
394
+ # Insert default plans
395
+ op.execute(
396
+ f"""
397
+ INSERT INTO plans (id, code, name) VALUES
398
+ ('{free_plan_id}', 'free', 'Free Plan'),
399
+ ('{pro_plan_id}', 'pro', 'Pro Plan')
400
+ """
401
+ )
402
+
403
+
404
+ def downgrade() -> None:
405
+ """Drop all tables in reverse order.
406
+
407
+ WARNING: This will DELETE ALL DATA.
408
+ Ensure you have backups before running in production.
409
+ """
410
+ # Drop in reverse order of creation (respecting foreign keys)
411
+ op.drop_table("feature_usage")
412
+ op.drop_table("user_plans")
413
+ op.drop_table("plan_permissions")
414
+ op.drop_table("plan_limits")
415
+ op.drop_table("features")
416
+ op.drop_table("plans")
417
+ op.drop_table("role_permissions")
418
+ op.drop_table("permissions")
419
+ # Drop partial index before dropping table
420
+ op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_hash_active")
421
+ op.drop_table("refresh_tokens")
422
+ op.drop_table("user_providers")
423
+ op.drop_table("users")
424
+ op.drop_table("roles")
alembic.ini ADDED
@@ -0,0 +1,76 @@
1
+ # Alembic configuration for IdentityPlanKit
2
+ # Database URL should be set via environment variable IPK_DATABASE_URL
3
+
4
+ [alembic]
5
+ # Path to migration scripts
6
+ script_location = alembic
7
+
8
+ # Template used to generate migration file names
9
+ file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
10
+
11
+ # Use sys.path insertion to find the package
12
+ prepend_sys_path = src
13
+
14
+ # Timezone to use when rendering the date within the migration file
15
+ # as well as the filename
16
+ timezone = UTC
17
+
18
+ # Max length of characters to apply to the "slug" field
19
+ truncate_slug_length = 40
20
+
21
+ # Set to 'true' to run the environment during the 'revision' command
22
+ revision_environment = false
23
+
24
+ # Set to 'true' to allow .pyc and .pyo files without a source .py file
25
+ sourceless = false
26
+
27
+ # Version path separator
28
+ version_path_separator = os
29
+
30
+ # Output encoding
31
+ output_encoding = utf-8
32
+
33
+ # Database URL (override with IPK_DATABASE_URL env var)
34
+ # For async, use postgresql+asyncpg://...
35
+ # Alembic needs sync driver: postgresql://...
36
+ # SECURITY: This placeholder URL will fail fast if accidentally used
37
+ # Always set IPK_DATABASE_URL environment variable for actual usage
38
+ sqlalchemy.url = SET_IPK_DATABASE_URL_ENVIRONMENT_VARIABLE
39
+
40
+
41
+ [post_write_hooks]
42
+ # Hooks for formatting migrations after generation
43
+
44
+ [loggers]
45
+ keys = root,sqlalchemy,alembic
46
+
47
+ [handlers]
48
+ keys = console
49
+
50
+ [formatters]
51
+ keys = generic
52
+
53
+ [logger_root]
54
+ level = WARN
55
+ handlers = console
56
+ qualname =
57
+
58
+ [logger_sqlalchemy]
59
+ level = WARN
60
+ handlers =
61
+ qualname = sqlalchemy.engine
62
+
63
+ [logger_alembic]
64
+ level = INFO
65
+ handlers =
66
+ qualname = alembic
67
+
68
+ [handler_console]
69
+ class = StreamHandler
70
+ args = (sys.stderr,)
71
+ level = NOTSET
72
+ formatter = generic
73
+
74
+ [formatter_generic]
75
+ format = %(levelname)-5.5s [%(name)s] %(message)s
76
+ datefmt = %H:%M:%S