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.
- alembic/env.py +131 -0
- alembic/script.py.mako +28 -0
- alembic/versions/20250124_000000_initial_schema.py +424 -0
- alembic.ini +76 -0
- identity_plan_kit/__init__.py +126 -0
- identity_plan_kit/admin/__init__.py +72 -0
- identity_plan_kit/admin/views.py +853 -0
- identity_plan_kit/auth/__init__.py +36 -0
- identity_plan_kit/auth/dependencies.py +139 -0
- identity_plan_kit/auth/domain/__init__.py +1 -0
- identity_plan_kit/auth/domain/entities.py +130 -0
- identity_plan_kit/auth/domain/exceptions.py +77 -0
- identity_plan_kit/auth/dto/__init__.py +15 -0
- identity_plan_kit/auth/dto/requests.py +44 -0
- identity_plan_kit/auth/dto/responses.py +147 -0
- identity_plan_kit/auth/handlers/__init__.py +5 -0
- identity_plan_kit/auth/handlers/oauth_routes.py +394 -0
- identity_plan_kit/auth/models/__init__.py +11 -0
- identity_plan_kit/auth/models/refresh_token.py +93 -0
- identity_plan_kit/auth/models/user.py +66 -0
- identity_plan_kit/auth/models/user_provider.py +53 -0
- identity_plan_kit/auth/repositories/__init__.py +9 -0
- identity_plan_kit/auth/repositories/token_repo.py +201 -0
- identity_plan_kit/auth/repositories/user_repo.py +398 -0
- identity_plan_kit/auth/services/__init__.py +9 -0
- identity_plan_kit/auth/services/auth_service.py +557 -0
- identity_plan_kit/auth/services/oauth_service.py +355 -0
- identity_plan_kit/auth/uow.py +59 -0
- identity_plan_kit/cli.py +384 -0
- identity_plan_kit/config.py +542 -0
- identity_plan_kit/kit.py +574 -0
- identity_plan_kit/migrations.py +249 -0
- identity_plan_kit/plans/__init__.py +41 -0
- identity_plan_kit/plans/dependencies.py +152 -0
- identity_plan_kit/plans/domain/__init__.py +1 -0
- identity_plan_kit/plans/domain/entities.py +136 -0
- identity_plan_kit/plans/domain/exceptions.py +116 -0
- identity_plan_kit/plans/dto/__init__.py +5 -0
- identity_plan_kit/plans/dto/usage.py +60 -0
- identity_plan_kit/plans/models/__init__.py +17 -0
- identity_plan_kit/plans/models/feature.py +31 -0
- identity_plan_kit/plans/models/feature_usage.py +72 -0
- identity_plan_kit/plans/models/plan.py +48 -0
- identity_plan_kit/plans/models/plan_limit.py +61 -0
- identity_plan_kit/plans/models/plan_permission.py +49 -0
- identity_plan_kit/plans/models/user_plan.py +86 -0
- identity_plan_kit/plans/repositories/__init__.py +6 -0
- identity_plan_kit/plans/repositories/plan_repo.py +439 -0
- identity_plan_kit/plans/repositories/usage_repo.py +299 -0
- identity_plan_kit/plans/services/__init__.py +5 -0
- identity_plan_kit/plans/services/plan_service.py +570 -0
- identity_plan_kit/plans/uow.py +47 -0
- identity_plan_kit/rbac/__init__.py +23 -0
- identity_plan_kit/rbac/cache/__init__.py +5 -0
- identity_plan_kit/rbac/cache/permission_cache.py +392 -0
- identity_plan_kit/rbac/dependencies.py +127 -0
- identity_plan_kit/rbac/domain/__init__.py +1 -0
- identity_plan_kit/rbac/domain/entities.py +63 -0
- identity_plan_kit/rbac/domain/exceptions.py +56 -0
- identity_plan_kit/rbac/models/__init__.py +11 -0
- identity_plan_kit/rbac/models/permission.py +34 -0
- identity_plan_kit/rbac/models/role.py +42 -0
- identity_plan_kit/rbac/models/role_permission.py +49 -0
- identity_plan_kit/rbac/repositories/__init__.py +5 -0
- identity_plan_kit/rbac/repositories/rbac_repo.py +206 -0
- identity_plan_kit/rbac/services/__init__.py +5 -0
- identity_plan_kit/rbac/services/rbac_service.py +231 -0
- identity_plan_kit/rbac/uow.py +43 -0
- identity_plan_kit/shared/__init__.py +157 -0
- identity_plan_kit/shared/audit.py +404 -0
- identity_plan_kit/shared/circuit_breaker.py +251 -0
- identity_plan_kit/shared/cleanup_scheduler.py +337 -0
- identity_plan_kit/shared/database.py +396 -0
- identity_plan_kit/shared/exception_handlers.py +416 -0
- identity_plan_kit/shared/exceptions.py +82 -0
- identity_plan_kit/shared/graceful_shutdown.py +327 -0
- identity_plan_kit/shared/health.py +257 -0
- identity_plan_kit/shared/http_utils.py +63 -0
- identity_plan_kit/shared/lockout.py +354 -0
- identity_plan_kit/shared/logging.py +123 -0
- identity_plan_kit/shared/metrics.py +420 -0
- identity_plan_kit/shared/models.py +100 -0
- identity_plan_kit/shared/rate_limiter.py +130 -0
- identity_plan_kit/shared/request_id.py +121 -0
- identity_plan_kit/shared/schemas.py +127 -0
- identity_plan_kit/shared/security.py +251 -0
- identity_plan_kit/shared/state_store.py +574 -0
- identity_plan_kit/shared/uow.py +142 -0
- identity_plan_kit/shared/uuid7.py +38 -0
- identity_plan_kit-0.1.0.dist-info/METADATA +514 -0
- identity_plan_kit-0.1.0.dist-info/RECORD +93 -0
- identity_plan_kit-0.1.0.dist-info/WHEEL +4 -0
- 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
|