identity-plan-kit 0.2.4__tar.gz → 0.2.5__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.
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/PKG-INFO +198 -1
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/README.md +197 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/pyproject.toml +2 -2
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/handlers/oauth_routes.py +3 -1
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/repositories/token_repo.py +10 -4
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/repositories/user_repo.py +11 -1
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/services/auth_service.py +37 -18
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/config.py +19 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/kit.py +35 -2
- identity_plan_kit-0.2.5/src/identity_plan_kit/plans/cache/plan_cache.py +236 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/domain/exceptions.py +30 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/handlers/plan_routes.py +4 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/repositories/plan_repo.py +12 -1
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/repositories/usage_repo.py +32 -28
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/services/plan_service.py +342 -7
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/security.py +80 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_security_comprehensive.py +108 -0
- identity_plan_kit-0.2.5/tests/integration/test_concurrent_limit_merge.py +391 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_concurrent_token_operations.py +4 -3
- identity_plan_kit-0.2.5/tests/integration/test_concurrent_user_deactivation.py +448 -0
- identity_plan_kit-0.2.5/tests/integration/test_plan_cache_race.py +404 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_quota_concurrency.py +8 -5
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/plans/test_plan_cache_concurrent.py +116 -0
- identity_plan_kit-0.2.5/tests/plans/test_plan_service.py +897 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/uv.lock +1 -1
- identity_plan_kit-0.2.4/src/identity_plan_kit/plans/cache/plan_cache.py +0 -143
- identity_plan_kit-0.2.4/tests/plans/test_plan_service.py +0 -431
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/.gitignore +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/Makefile +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/alembic/env.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/alembic/script.py.mako +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/alembic/versions/20250124_000000_initial_schema.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/alembic/versions/20250127_000000_add_password_hash.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/alembic.ini +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/integration/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/integration/fastapi_integration.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/integration/sample_alembic_env.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/integration/webhook_integration.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/playing/admin_usage.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/playing/basic_usage.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/playing/custom_error_formats.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/playing/extending_models.html +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/playing/extension_example.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/prod/.env.production.example +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/prod/Dockerfile +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/prod/docker-compose.yml +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/prod/nginx.conf +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/prod/production_setup.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/examples/prod/prometheus.yml +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/README.md +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/config.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/locustfile.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/test_auth.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/test_database_stress.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/test_mixed_scenarios.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/test_plans_quota.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/test_quota_direct.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/test_rbac_cache.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/loadtests/utils.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/admin/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/admin/auth.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/admin/views.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/dependencies.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/domain/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/domain/entities.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/domain/exceptions.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/dto/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/dto/requests.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/dto/responses.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/handlers/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/models/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/models/refresh_token.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/models/user.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/models/user_provider.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/repositories/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/services/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/services/oauth_service.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/auth/uow.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/cli.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/migrations.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/cache/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/dependencies.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/domain/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/domain/entities.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/dto/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/dto/responses.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/dto/usage.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/handlers/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/models/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/models/feature.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/models/feature_usage.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/models/plan.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/models/plan_limit.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/models/plan_permission.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/models/user_plan.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/repositories/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/services/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/plans/uow.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/cache/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/cache/permission_cache.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/dependencies.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/domain/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/domain/entities.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/domain/exceptions.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/models/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/models/permission.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/models/role.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/models/role_permission.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/repositories/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/repositories/rbac_repo.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/services/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/services/rbac_service.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/rbac/uow.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/audit.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/circuit_breaker.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/cleanup_scheduler.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/database.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/error_formatter.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/exception_handlers.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/exceptions.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/graceful_shutdown.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/health.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/http_utils.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/lockout.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/logging.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/metrics.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/models.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/rate_limiter.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/request_id.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/schemas.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/state_store.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/uow.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/src/identity_plan_kit/shared/uuid7.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_auth_service.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_circuit_breaker.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_csrf_state.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_dependencies.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_domain_entities.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_http_utils.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_lockout.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_lockout_concurrent.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_oauth_service_resilience.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/auth/test_token_security.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/conftest.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/conftest.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_concurrent_plan_operations.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_concurrent_user_creation.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_limit_boundaries.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_token_cleanup.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_token_repository.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_usage_periods.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/integration/test_user_repository.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/plans/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/plans/test_domain_entities.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/plans/test_period_edge_cases.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/rbac/__init__.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/rbac/test_permission_cache.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/rbac/test_permission_cache_concurrent.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/rbac/test_rbac_cache_stampede.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/rbac/test_rbac_service.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/shared/test_audit_pii_masking.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/shared/test_circuit_breaker_concurrent.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/shared/test_cleanup_scheduler_concurrent.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/shared/test_config_validation.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/shared/test_http_utils_security.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/shared/test_rate_limiter.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/shared/test_rate_limiter_concurrent.py +0 -0
- {identity_plan_kit-0.2.4 → identity_plan_kit-0.2.5}/tests/shared/test_state_store_concurrent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: identity-plan-kit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: Modern FastAPI library for authentication, RBAC, subscription plans, and usage tracking
|
|
5
5
|
Author-email: harut <harut.avetisyan2002@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -73,6 +73,183 @@ Modern FastAPI library for authentication, RBAC, subscription plans, and usage t
|
|
|
73
73
|
- **Account Lockout**: Brute-force protection
|
|
74
74
|
- **CLI Tools**: Database migrations and management
|
|
75
75
|
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## What IPK Provides vs What You Must Implement
|
|
79
|
+
|
|
80
|
+
### Provided Out of the Box
|
|
81
|
+
|
|
82
|
+
| Feature | Description |
|
|
83
|
+
|---------|-------------|
|
|
84
|
+
| **Google OAuth Authentication** | Complete OAuth 2.0 flow with CSRF protection |
|
|
85
|
+
| **JWT Token Management** | Access/refresh token generation, validation, and rotation |
|
|
86
|
+
| **Token Theft Detection** | Automatic account deactivation when revoked token is reused |
|
|
87
|
+
| **Default Roles** | Pre-configured `admin` and `user` roles |
|
|
88
|
+
| **Default Plans** | Pre-configured `free` and `pro` plans |
|
|
89
|
+
| **Permission Infrastructure** | `check_permission()`, `require_permission()` methods |
|
|
90
|
+
| **Atomic Quota Checking** | Race-condition safe quota consumption (TOCTOU protected) |
|
|
91
|
+
| **Account Lockout** | Brute-force protection with configurable thresholds |
|
|
92
|
+
| **Token Cleanup** | Automatic expired token removal |
|
|
93
|
+
| **Health Checks** | Kubernetes-ready `/health/live` and `/health/ready` probes |
|
|
94
|
+
| **Graceful Shutdown** | Request draining before shutdown |
|
|
95
|
+
| **Database Migrations** | All tables created via `ipk db upgrade` |
|
|
96
|
+
| **Prometheus Metrics** | Optional observability (requires `[metrics]` extra) |
|
|
97
|
+
| **Audit Logging** | Security events logged automatically |
|
|
98
|
+
| **Idempotent Token Refresh** | Duplicate refresh requests return same tokens (30s window) |
|
|
99
|
+
| **Idempotent Quota Consumption** | When `idempotency_key` provided, prevents double-deduction |
|
|
100
|
+
| **Rate Limiting** | Configurable per-endpoint rate limits |
|
|
101
|
+
|
|
102
|
+
### You Must Implement
|
|
103
|
+
|
|
104
|
+
| Responsibility | Why | Example |
|
|
105
|
+
|----------------|-----|---------|
|
|
106
|
+
| **Define Features** | IPK doesn't know what features your app has | `"api_calls"`, `"ai_generations"`, `"storage_gb"` |
|
|
107
|
+
| **Assign Features to Plans** | Business decision about what's included | Free: 100 API calls, Pro: unlimited |
|
|
108
|
+
| **Set Plan Limits** | Your pricing/quota decisions | `{"api_calls": 1000, "ai_generations": 50}` |
|
|
109
|
+
| **Define Permission Codes** | App-specific access control | `"admin.users.delete"`, `"reports.export"` |
|
|
110
|
+
| **Assign Permissions to Roles** | Which roles can do what | Admin gets `"admin.*"`, User gets `"feature.use"` |
|
|
111
|
+
| **Payment Webhook Integration** | IPK doesn't handle payments | Call `plan_service.assign_plan()` from Stripe webhook |
|
|
112
|
+
| **Webhook Deduplication** | Payment providers retry webhooks | Check `event_id` before processing (see below) |
|
|
113
|
+
| **Quota Checks in Routes** | IPK doesn't auto-enforce quotas | Call `check_and_consume_quota()` in your endpoints |
|
|
114
|
+
| **Authorization for Plan Ops** | IPK doesn't verify callers by default | Verify webhook signatures, check admin role |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Critical Warnings
|
|
119
|
+
|
|
120
|
+
### Production Deployments (Multi-Instance)
|
|
121
|
+
|
|
122
|
+
> **Redis is REQUIRED for multi-instance deployments**
|
|
123
|
+
|
|
124
|
+
Without Redis, each instance has its own:
|
|
125
|
+
- OAuth state storage (login fails if callback hits different instance)
|
|
126
|
+
- Rate limit counters (users bypass limits via different instances)
|
|
127
|
+
- Permission cache (inconsistent permissions across instances)
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Set Redis URL for production
|
|
131
|
+
IPK_REDIS_URL=redis://localhost:6379/0
|
|
132
|
+
IPK_REQUIRE_REDIS=true # Fail-fast if Redis unavailable
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Token Theft Protection
|
|
136
|
+
|
|
137
|
+
When a refresh token is used **after being revoked**, IPK assumes token theft:
|
|
138
|
+
1. ALL tokens for that user are revoked
|
|
139
|
+
2. User account is **automatically deactivated**
|
|
140
|
+
3. User must contact support to reactivate
|
|
141
|
+
|
|
142
|
+
This is intentional security behavior, not a bug.
|
|
143
|
+
|
|
144
|
+
### Webhook Idempotency (NOT Auto-Handled)
|
|
145
|
+
|
|
146
|
+
**Plan management methods do NOT handle idempotency.** If your webhook provider retries requests (Stripe retries on timeout), duplicate calls will create issues.
|
|
147
|
+
|
|
148
|
+
**You MUST implement deduplication:**
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
@app.post("/webhooks/stripe")
|
|
152
|
+
async def stripe_webhook(request: Request):
|
|
153
|
+
event = stripe.Webhook.construct_event(...)
|
|
154
|
+
|
|
155
|
+
# CRITICAL: Check if already processed
|
|
156
|
+
if await is_event_processed(event.id):
|
|
157
|
+
return {"status": "already_processed"}
|
|
158
|
+
|
|
159
|
+
if event.type == "checkout.session.completed":
|
|
160
|
+
await kit.plan_service.assign_plan(
|
|
161
|
+
user_id=UUID(event.data.object.client_reference_id),
|
|
162
|
+
plan_code="pro",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
await mark_event_processed(event.id)
|
|
166
|
+
return {"status": "ok"}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Authorization is Your Responsibility
|
|
170
|
+
|
|
171
|
+
Plan management methods (`assign_plan`, `cancel_plan`, `update_plan_limits`, `reset_usage`) are **privileged operations**. IPK does NOT verify the caller is authorized.
|
|
172
|
+
|
|
173
|
+
**You MUST verify authorization before calling:**
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
# Option 1: Verify in your code before calling
|
|
177
|
+
@app.post("/admin/users/{user_id}/plan")
|
|
178
|
+
async def admin_assign_plan(user_id: UUID, current_user: User = Depends(CurrentUser(kit))):
|
|
179
|
+
# Verify caller is admin
|
|
180
|
+
await kit.rbac_service.require_permission(
|
|
181
|
+
current_user.id, current_user.role_id, "admin.plans.manage"
|
|
182
|
+
)
|
|
183
|
+
await kit.plan_service.assign_plan(user_id=user_id, plan_code="pro")
|
|
184
|
+
|
|
185
|
+
# Option 2: Configure authorization callback
|
|
186
|
+
async def check_authorization(operation: str, target_user_id: UUID, context: dict | None) -> bool:
|
|
187
|
+
if context and context.get("is_webhook"):
|
|
188
|
+
return True # Verified webhooks are authorized
|
|
189
|
+
if context and context.get("is_admin"):
|
|
190
|
+
return True
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
kit = IdentityPlanKit(config, authorization_callback=check_authorization)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Integration Examples
|
|
199
|
+
|
|
200
|
+
### Quota Tracking in Routes
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
@app.post("/generate")
|
|
204
|
+
async def generate(user: User = Depends(CurrentUser(kit))):
|
|
205
|
+
usage = await kit.plan_service.check_and_consume_quota(
|
|
206
|
+
user_id=user.id,
|
|
207
|
+
feature_code="ai_generations",
|
|
208
|
+
amount=1,
|
|
209
|
+
idempotency_key=request.headers.get("X-Idempotency-Key"), # Safe retries
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if not usage.has_access:
|
|
213
|
+
raise HTTPException(
|
|
214
|
+
status_code=429,
|
|
215
|
+
detail=f"Quota exceeded: {usage.used}/{usage.limit}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Your feature logic here
|
|
219
|
+
return {"remaining": usage.remaining}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Permission Enforcement
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
@app.delete("/admin/users/{user_id}")
|
|
226
|
+
async def delete_user(user_id: UUID, current_user: User = Depends(CurrentUser(kit))):
|
|
227
|
+
await kit.rbac_service.require_permission(
|
|
228
|
+
user_id=current_user.id,
|
|
229
|
+
role_id=current_user.role_id,
|
|
230
|
+
permission_code="admin.users.delete",
|
|
231
|
+
)
|
|
232
|
+
# Delete logic here
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Idempotency for Quota Consumption
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
# Generate a unique key per logical operation
|
|
239
|
+
idempotency_key = f"{user.id}:{request_id}:generate_image"
|
|
240
|
+
|
|
241
|
+
# First request: checks quota, consumes 1, caches result
|
|
242
|
+
# Retry request: returns cached result, no double-deduction
|
|
243
|
+
result = await kit.plan_service.check_and_consume_quota(
|
|
244
|
+
user_id=user.id,
|
|
245
|
+
feature_code="ai_generation",
|
|
246
|
+
amount=1,
|
|
247
|
+
idempotency_key=idempotency_key,
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
76
253
|
## Installation
|
|
77
254
|
|
|
78
255
|
```bash
|
|
@@ -407,6 +584,26 @@ All configuration is via environment variables or `IdentityPlanKitConfig`:
|
|
|
407
584
|
| `IPK_METRICS_PATH` | Path for metrics endpoint | `/metrics` |
|
|
408
585
|
| `IPK_ENABLE_AUTO_CLEANUP` | Auto cleanup expired tokens | `true` |
|
|
409
586
|
| `IPK_CLEANUP_INTERVAL_HOURS` | Cleanup interval in hours | `6.0` |
|
|
587
|
+
| `IPK_DATABASE_STATEMENT_TIMEOUT_MS` | PostgreSQL statement timeout | `30000` |
|
|
588
|
+
| `IPK_TOKEN_REFRESH_IDEMPOTENCY_TTL_SECONDS` | Token refresh idempotency window | `30` |
|
|
589
|
+
| `IPK_QUOTA_IDEMPOTENCY_TTL_SECONDS` | Quota consumption idempotency window | `60` |
|
|
590
|
+
| `IPK_REQUIRE_REDIS` | Fail if Redis unavailable | Auto (true in prod) |
|
|
591
|
+
|
|
592
|
+
### Important Configuration Notes
|
|
593
|
+
|
|
594
|
+
**Database Statement Timeout**: Long-running queries are killed after 30s by default. Adjust for migrations:
|
|
595
|
+
|
|
596
|
+
```bash
|
|
597
|
+
# For API servers (shorter timeout)
|
|
598
|
+
IPK_DATABASE_STATEMENT_TIMEOUT_MS=10000 # 10 seconds
|
|
599
|
+
|
|
600
|
+
# For migration scripts (longer timeout)
|
|
601
|
+
IPK_DATABASE_STATEMENT_TIMEOUT_MS=300000 # 5 minutes
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**Token Refresh Idempotency**: Duplicate refresh requests within 30s return the same tokens. This prevents "token revoked" errors when clients retry due to network issues.
|
|
605
|
+
|
|
606
|
+
**Quota Idempotency**: When `idempotency_key` is provided to `check_and_consume_quota()`, duplicate requests within 60s return cached results without double-deducting.
|
|
410
607
|
|
|
411
608
|
## Prometheus Metrics (Optional)
|
|
412
609
|
|
|
@@ -13,6 +13,183 @@ Modern FastAPI library for authentication, RBAC, subscription plans, and usage t
|
|
|
13
13
|
- **Account Lockout**: Brute-force protection
|
|
14
14
|
- **CLI Tools**: Database migrations and management
|
|
15
15
|
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## What IPK Provides vs What You Must Implement
|
|
19
|
+
|
|
20
|
+
### Provided Out of the Box
|
|
21
|
+
|
|
22
|
+
| Feature | Description |
|
|
23
|
+
|---------|-------------|
|
|
24
|
+
| **Google OAuth Authentication** | Complete OAuth 2.0 flow with CSRF protection |
|
|
25
|
+
| **JWT Token Management** | Access/refresh token generation, validation, and rotation |
|
|
26
|
+
| **Token Theft Detection** | Automatic account deactivation when revoked token is reused |
|
|
27
|
+
| **Default Roles** | Pre-configured `admin` and `user` roles |
|
|
28
|
+
| **Default Plans** | Pre-configured `free` and `pro` plans |
|
|
29
|
+
| **Permission Infrastructure** | `check_permission()`, `require_permission()` methods |
|
|
30
|
+
| **Atomic Quota Checking** | Race-condition safe quota consumption (TOCTOU protected) |
|
|
31
|
+
| **Account Lockout** | Brute-force protection with configurable thresholds |
|
|
32
|
+
| **Token Cleanup** | Automatic expired token removal |
|
|
33
|
+
| **Health Checks** | Kubernetes-ready `/health/live` and `/health/ready` probes |
|
|
34
|
+
| **Graceful Shutdown** | Request draining before shutdown |
|
|
35
|
+
| **Database Migrations** | All tables created via `ipk db upgrade` |
|
|
36
|
+
| **Prometheus Metrics** | Optional observability (requires `[metrics]` extra) |
|
|
37
|
+
| **Audit Logging** | Security events logged automatically |
|
|
38
|
+
| **Idempotent Token Refresh** | Duplicate refresh requests return same tokens (30s window) |
|
|
39
|
+
| **Idempotent Quota Consumption** | When `idempotency_key` provided, prevents double-deduction |
|
|
40
|
+
| **Rate Limiting** | Configurable per-endpoint rate limits |
|
|
41
|
+
|
|
42
|
+
### You Must Implement
|
|
43
|
+
|
|
44
|
+
| Responsibility | Why | Example |
|
|
45
|
+
|----------------|-----|---------|
|
|
46
|
+
| **Define Features** | IPK doesn't know what features your app has | `"api_calls"`, `"ai_generations"`, `"storage_gb"` |
|
|
47
|
+
| **Assign Features to Plans** | Business decision about what's included | Free: 100 API calls, Pro: unlimited |
|
|
48
|
+
| **Set Plan Limits** | Your pricing/quota decisions | `{"api_calls": 1000, "ai_generations": 50}` |
|
|
49
|
+
| **Define Permission Codes** | App-specific access control | `"admin.users.delete"`, `"reports.export"` |
|
|
50
|
+
| **Assign Permissions to Roles** | Which roles can do what | Admin gets `"admin.*"`, User gets `"feature.use"` |
|
|
51
|
+
| **Payment Webhook Integration** | IPK doesn't handle payments | Call `plan_service.assign_plan()` from Stripe webhook |
|
|
52
|
+
| **Webhook Deduplication** | Payment providers retry webhooks | Check `event_id` before processing (see below) |
|
|
53
|
+
| **Quota Checks in Routes** | IPK doesn't auto-enforce quotas | Call `check_and_consume_quota()` in your endpoints |
|
|
54
|
+
| **Authorization for Plan Ops** | IPK doesn't verify callers by default | Verify webhook signatures, check admin role |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Critical Warnings
|
|
59
|
+
|
|
60
|
+
### Production Deployments (Multi-Instance)
|
|
61
|
+
|
|
62
|
+
> **Redis is REQUIRED for multi-instance deployments**
|
|
63
|
+
|
|
64
|
+
Without Redis, each instance has its own:
|
|
65
|
+
- OAuth state storage (login fails if callback hits different instance)
|
|
66
|
+
- Rate limit counters (users bypass limits via different instances)
|
|
67
|
+
- Permission cache (inconsistent permissions across instances)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Set Redis URL for production
|
|
71
|
+
IPK_REDIS_URL=redis://localhost:6379/0
|
|
72
|
+
IPK_REQUIRE_REDIS=true # Fail-fast if Redis unavailable
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Token Theft Protection
|
|
76
|
+
|
|
77
|
+
When a refresh token is used **after being revoked**, IPK assumes token theft:
|
|
78
|
+
1. ALL tokens for that user are revoked
|
|
79
|
+
2. User account is **automatically deactivated**
|
|
80
|
+
3. User must contact support to reactivate
|
|
81
|
+
|
|
82
|
+
This is intentional security behavior, not a bug.
|
|
83
|
+
|
|
84
|
+
### Webhook Idempotency (NOT Auto-Handled)
|
|
85
|
+
|
|
86
|
+
**Plan management methods do NOT handle idempotency.** If your webhook provider retries requests (Stripe retries on timeout), duplicate calls will create issues.
|
|
87
|
+
|
|
88
|
+
**You MUST implement deduplication:**
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
@app.post("/webhooks/stripe")
|
|
92
|
+
async def stripe_webhook(request: Request):
|
|
93
|
+
event = stripe.Webhook.construct_event(...)
|
|
94
|
+
|
|
95
|
+
# CRITICAL: Check if already processed
|
|
96
|
+
if await is_event_processed(event.id):
|
|
97
|
+
return {"status": "already_processed"}
|
|
98
|
+
|
|
99
|
+
if event.type == "checkout.session.completed":
|
|
100
|
+
await kit.plan_service.assign_plan(
|
|
101
|
+
user_id=UUID(event.data.object.client_reference_id),
|
|
102
|
+
plan_code="pro",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
await mark_event_processed(event.id)
|
|
106
|
+
return {"status": "ok"}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Authorization is Your Responsibility
|
|
110
|
+
|
|
111
|
+
Plan management methods (`assign_plan`, `cancel_plan`, `update_plan_limits`, `reset_usage`) are **privileged operations**. IPK does NOT verify the caller is authorized.
|
|
112
|
+
|
|
113
|
+
**You MUST verify authorization before calling:**
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
# Option 1: Verify in your code before calling
|
|
117
|
+
@app.post("/admin/users/{user_id}/plan")
|
|
118
|
+
async def admin_assign_plan(user_id: UUID, current_user: User = Depends(CurrentUser(kit))):
|
|
119
|
+
# Verify caller is admin
|
|
120
|
+
await kit.rbac_service.require_permission(
|
|
121
|
+
current_user.id, current_user.role_id, "admin.plans.manage"
|
|
122
|
+
)
|
|
123
|
+
await kit.plan_service.assign_plan(user_id=user_id, plan_code="pro")
|
|
124
|
+
|
|
125
|
+
# Option 2: Configure authorization callback
|
|
126
|
+
async def check_authorization(operation: str, target_user_id: UUID, context: dict | None) -> bool:
|
|
127
|
+
if context and context.get("is_webhook"):
|
|
128
|
+
return True # Verified webhooks are authorized
|
|
129
|
+
if context and context.get("is_admin"):
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
kit = IdentityPlanKit(config, authorization_callback=check_authorization)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Integration Examples
|
|
139
|
+
|
|
140
|
+
### Quota Tracking in Routes
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
@app.post("/generate")
|
|
144
|
+
async def generate(user: User = Depends(CurrentUser(kit))):
|
|
145
|
+
usage = await kit.plan_service.check_and_consume_quota(
|
|
146
|
+
user_id=user.id,
|
|
147
|
+
feature_code="ai_generations",
|
|
148
|
+
amount=1,
|
|
149
|
+
idempotency_key=request.headers.get("X-Idempotency-Key"), # Safe retries
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if not usage.has_access:
|
|
153
|
+
raise HTTPException(
|
|
154
|
+
status_code=429,
|
|
155
|
+
detail=f"Quota exceeded: {usage.used}/{usage.limit}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Your feature logic here
|
|
159
|
+
return {"remaining": usage.remaining}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Permission Enforcement
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
@app.delete("/admin/users/{user_id}")
|
|
166
|
+
async def delete_user(user_id: UUID, current_user: User = Depends(CurrentUser(kit))):
|
|
167
|
+
await kit.rbac_service.require_permission(
|
|
168
|
+
user_id=current_user.id,
|
|
169
|
+
role_id=current_user.role_id,
|
|
170
|
+
permission_code="admin.users.delete",
|
|
171
|
+
)
|
|
172
|
+
# Delete logic here
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Idempotency for Quota Consumption
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
# Generate a unique key per logical operation
|
|
179
|
+
idempotency_key = f"{user.id}:{request_id}:generate_image"
|
|
180
|
+
|
|
181
|
+
# First request: checks quota, consumes 1, caches result
|
|
182
|
+
# Retry request: returns cached result, no double-deduction
|
|
183
|
+
result = await kit.plan_service.check_and_consume_quota(
|
|
184
|
+
user_id=user.id,
|
|
185
|
+
feature_code="ai_generation",
|
|
186
|
+
amount=1,
|
|
187
|
+
idempotency_key=idempotency_key,
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
16
193
|
## Installation
|
|
17
194
|
|
|
18
195
|
```bash
|
|
@@ -347,6 +524,26 @@ All configuration is via environment variables or `IdentityPlanKitConfig`:
|
|
|
347
524
|
| `IPK_METRICS_PATH` | Path for metrics endpoint | `/metrics` |
|
|
348
525
|
| `IPK_ENABLE_AUTO_CLEANUP` | Auto cleanup expired tokens | `true` |
|
|
349
526
|
| `IPK_CLEANUP_INTERVAL_HOURS` | Cleanup interval in hours | `6.0` |
|
|
527
|
+
| `IPK_DATABASE_STATEMENT_TIMEOUT_MS` | PostgreSQL statement timeout | `30000` |
|
|
528
|
+
| `IPK_TOKEN_REFRESH_IDEMPOTENCY_TTL_SECONDS` | Token refresh idempotency window | `30` |
|
|
529
|
+
| `IPK_QUOTA_IDEMPOTENCY_TTL_SECONDS` | Quota consumption idempotency window | `60` |
|
|
530
|
+
| `IPK_REQUIRE_REDIS` | Fail if Redis unavailable | Auto (true in prod) |
|
|
531
|
+
|
|
532
|
+
### Important Configuration Notes
|
|
533
|
+
|
|
534
|
+
**Database Statement Timeout**: Long-running queries are killed after 30s by default. Adjust for migrations:
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
# For API servers (shorter timeout)
|
|
538
|
+
IPK_DATABASE_STATEMENT_TIMEOUT_MS=10000 # 10 seconds
|
|
539
|
+
|
|
540
|
+
# For migration scripts (longer timeout)
|
|
541
|
+
IPK_DATABASE_STATEMENT_TIMEOUT_MS=300000 # 5 minutes
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
**Token Refresh Idempotency**: Duplicate refresh requests within 30s return the same tokens. This prevents "token revoked" errors when clients retry due to network issues.
|
|
545
|
+
|
|
546
|
+
**Quota Idempotency**: When `idempotency_key` is provided to `check_and_consume_quota()`, duplicate requests within 60s return cached results without double-deducting.
|
|
350
547
|
|
|
351
548
|
## Prometheus Metrics (Optional)
|
|
352
549
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "identity-plan-kit"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.5"
|
|
4
4
|
description = "Modern FastAPI library for authentication, RBAC, subscription plans, and usage tracking"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -140,7 +140,7 @@ testpaths = ["tests"]
|
|
|
140
140
|
addopts = "-v --tb=short"
|
|
141
141
|
|
|
142
142
|
[tool.bumpversion]
|
|
143
|
-
current_version = "0.2.
|
|
143
|
+
current_version = "0.2.5"
|
|
144
144
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
145
145
|
serialize = ["{major}.{minor}.{patch}"]
|
|
146
146
|
tag = true
|
|
@@ -294,7 +294,8 @@ def create_auth_router(config: IdentityPlanKitConfig) -> APIRouter: # noqa: PLR
|
|
|
294
294
|
summary="Get current user",
|
|
295
295
|
description="Get information about the authenticated user",
|
|
296
296
|
)
|
|
297
|
-
|
|
297
|
+
@rate_limiter.limit(config.rate_limit_profile)
|
|
298
|
+
async def get_me(request: Request, user: CurrentUser) -> ResponseModel[UserResponse]:
|
|
298
299
|
"""Get current authenticated user."""
|
|
299
300
|
return ResponseModel.ok(
|
|
300
301
|
data=UserResponse(
|
|
@@ -313,6 +314,7 @@ def create_auth_router(config: IdentityPlanKitConfig) -> APIRouter: # noqa: PLR
|
|
|
313
314
|
summary="Get user profile",
|
|
314
315
|
description="Get complete user profile including role, permissions, and current plan",
|
|
315
316
|
)
|
|
317
|
+
@rate_limiter.limit(config.rate_limit_profile)
|
|
316
318
|
async def get_profile(request: Request, user: CurrentUser) -> ResponseModel[ProfileResponse]:
|
|
317
319
|
"""Get complete user profile with permissions and plan."""
|
|
318
320
|
kit = request.app.state.identity_plan_kit
|
|
@@ -146,6 +146,9 @@ class RefreshTokenRepository:
|
|
|
146
146
|
Uses batch deletion to prevent long-running transactions and
|
|
147
147
|
table-level locks on large datasets.
|
|
148
148
|
|
|
149
|
+
Uses FOR UPDATE SKIP LOCKED to allow concurrent cleanup operations
|
|
150
|
+
to process different batches of tokens without conflicts.
|
|
151
|
+
|
|
149
152
|
Should be run periodically via background job.
|
|
150
153
|
|
|
151
154
|
Args:
|
|
@@ -157,7 +160,8 @@ class RefreshTokenRepository:
|
|
|
157
160
|
# Import here - delete is only needed for cleanup operations
|
|
158
161
|
from sqlalchemy import delete # noqa: PLC0415
|
|
159
162
|
|
|
160
|
-
#
|
|
163
|
+
# Select IDs to delete with SKIP LOCKED to allow concurrent cleanup
|
|
164
|
+
# Each concurrent call will get a different batch of rows
|
|
161
165
|
select_stmt = (
|
|
162
166
|
select(RefreshTokenModel.id)
|
|
163
167
|
.where(
|
|
@@ -165,6 +169,7 @@ class RefreshTokenRepository:
|
|
|
165
169
|
| (RefreshTokenModel.revoked_at.is_not(None))
|
|
166
170
|
)
|
|
167
171
|
.limit(batch_size)
|
|
172
|
+
.with_for_update(skip_locked=True)
|
|
168
173
|
)
|
|
169
174
|
result = await self._session.execute(select_stmt)
|
|
170
175
|
ids_to_delete = [row[0] for row in result.fetchall()]
|
|
@@ -172,12 +177,13 @@ class RefreshTokenRepository:
|
|
|
172
177
|
if not ids_to_delete:
|
|
173
178
|
return 0
|
|
174
179
|
|
|
175
|
-
# Delete by IDs
|
|
180
|
+
# Delete by IDs and get actual count
|
|
176
181
|
delete_stmt = delete(RefreshTokenModel).where(RefreshTokenModel.id.in_(ids_to_delete))
|
|
177
|
-
await self._session.execute(delete_stmt)
|
|
182
|
+
delete_result = await self._session.execute(delete_stmt)
|
|
178
183
|
await self._session.flush()
|
|
179
184
|
|
|
180
|
-
|
|
185
|
+
# Use rowcount from DELETE to get actual deleted count
|
|
186
|
+
count = delete_result.rowcount
|
|
181
187
|
logger.info(
|
|
182
188
|
"refresh_tokens_cleaned",
|
|
183
189
|
count=count,
|
|
@@ -22,12 +22,18 @@ class UserRepository:
|
|
|
22
22
|
def __init__(self, session: AsyncSession) -> None:
|
|
23
23
|
self._session = session
|
|
24
24
|
|
|
25
|
-
async def get_by_id(
|
|
25
|
+
async def get_by_id(
|
|
26
|
+
self,
|
|
27
|
+
user_id: UUID,
|
|
28
|
+
for_update: bool = False,
|
|
29
|
+
) -> User | None:
|
|
26
30
|
"""
|
|
27
31
|
Get user by ID.
|
|
28
32
|
|
|
29
33
|
Args:
|
|
30
34
|
user_id: User UUID
|
|
35
|
+
for_update: If True, lock the row for update (prevents race conditions
|
|
36
|
+
in operations that depend on current user state like is_active)
|
|
31
37
|
|
|
32
38
|
Returns:
|
|
33
39
|
User entity or None if not found
|
|
@@ -35,6 +41,10 @@ class UserRepository:
|
|
|
35
41
|
stmt = (
|
|
36
42
|
select(UserModel).options(selectinload(UserModel.role)).where(UserModel.id == user_id)
|
|
37
43
|
)
|
|
44
|
+
|
|
45
|
+
if for_update:
|
|
46
|
+
stmt = stmt.with_for_update()
|
|
47
|
+
|
|
38
48
|
result = await self._session.execute(stmt)
|
|
39
49
|
model = result.scalar_one_or_none()
|
|
40
50
|
|
|
@@ -37,6 +37,8 @@ from identity_plan_kit.shared.security import (
|
|
|
37
37
|
create_access_token,
|
|
38
38
|
create_refresh_token,
|
|
39
39
|
decode_token,
|
|
40
|
+
decrypt_from_cache,
|
|
41
|
+
encrypt_for_cache,
|
|
40
42
|
hash_password,
|
|
41
43
|
hash_token,
|
|
42
44
|
verify_password,
|
|
@@ -347,17 +349,28 @@ class AuthService:
|
|
|
347
349
|
|
|
348
350
|
# Return the EXACT same tokens that were generated on first request
|
|
349
351
|
# This ensures true idempotency - client gets identical response
|
|
350
|
-
|
|
351
|
-
|
|
352
|
+
# SECURITY: Tokens are encrypted in cache - decrypt them
|
|
353
|
+
encrypted_access = cached_data.get("access_token_enc")
|
|
354
|
+
encrypted_refresh = cached_data.get("refresh_token_enc")
|
|
352
355
|
|
|
353
|
-
if
|
|
354
|
-
|
|
356
|
+
if encrypted_access and encrypted_refresh:
|
|
357
|
+
cached_access = decrypt_from_cache(encrypted_access, self._secret_key)
|
|
358
|
+
cached_refresh = decrypt_from_cache(encrypted_refresh, self._secret_key)
|
|
355
359
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
360
|
+
if cached_access and cached_refresh:
|
|
361
|
+
return user, cached_access, cached_refresh
|
|
362
|
+
|
|
363
|
+
# Decryption failed - possible key rotation or tampering
|
|
364
|
+
logger.warning(
|
|
365
|
+
"token_refresh_idempotency_decryption_failed",
|
|
366
|
+
user_id=str(user.id),
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
# Fallback: cache entry malformed, proceed with normal flow
|
|
370
|
+
logger.warning(
|
|
371
|
+
"token_refresh_idempotency_cache_malformed",
|
|
372
|
+
user_id=str(user.id),
|
|
373
|
+
)
|
|
361
374
|
|
|
362
375
|
async with self._create_uow() as uow:
|
|
363
376
|
# Find token by hash with row lock (prevents race condition)
|
|
@@ -405,7 +418,8 @@ class AuthService:
|
|
|
405
418
|
|
|
406
419
|
# P1 SECURITY FIX: Deactivate user to prevent further access
|
|
407
420
|
# User must contact support to reactivate
|
|
408
|
-
|
|
421
|
+
# Use FOR UPDATE to prevent race condition with concurrent requests
|
|
422
|
+
user = await uow.users.get_by_id(stored_token.user_id, for_update=True)
|
|
409
423
|
if user and user.is_active:
|
|
410
424
|
await uow.users.deactivate(
|
|
411
425
|
stored_token.user_id,
|
|
@@ -418,8 +432,11 @@ class AuthService:
|
|
|
418
432
|
|
|
419
433
|
raise TokenInvalidError("Token has been revoked - account secured")
|
|
420
434
|
|
|
421
|
-
# Get user
|
|
422
|
-
user
|
|
435
|
+
# Get user with FOR UPDATE lock to prevent race condition
|
|
436
|
+
# where user is deactivated between this check and token creation
|
|
437
|
+
# Without this, a deactivated user could get new tokens if the
|
|
438
|
+
# deactivation happens after this read but before the commit
|
|
439
|
+
user = await uow.users.get_by_id(stored_token.user_id, for_update=True)
|
|
423
440
|
if user is None:
|
|
424
441
|
raise UserNotFoundError()
|
|
425
442
|
|
|
@@ -452,14 +469,15 @@ class AuthService:
|
|
|
452
469
|
|
|
453
470
|
# Cache the actual tokens for idempotency
|
|
454
471
|
# This ensures retries get the EXACT same tokens (true idempotency)
|
|
455
|
-
#
|
|
456
|
-
#
|
|
472
|
+
# SECURITY: Encrypt tokens before storing in cache to prevent
|
|
473
|
+
# exposure if cache (Redis) is compromised. Uses Fernet encryption
|
|
474
|
+
# with key derived from secret_key.
|
|
457
475
|
await state_store.set(
|
|
458
476
|
idempotency_key,
|
|
459
477
|
{
|
|
460
478
|
"user_id": str(user.id),
|
|
461
|
-
"
|
|
462
|
-
"
|
|
479
|
+
"access_token_enc": encrypt_for_cache(access_token, self._secret_key),
|
|
480
|
+
"refresh_token_enc": encrypt_for_cache(new_refresh_token, self._secret_key),
|
|
463
481
|
},
|
|
464
482
|
ttl_seconds=self._config.token_refresh_idempotency_ttl_seconds,
|
|
465
483
|
)
|
|
@@ -610,8 +628,9 @@ class AuthService:
|
|
|
610
628
|
self._validate_password(password)
|
|
611
629
|
|
|
612
630
|
async with self._create_uow() as uow:
|
|
613
|
-
# Get user with lock to prevent race conditions
|
|
614
|
-
user
|
|
631
|
+
# Get user with FOR UPDATE lock to prevent race conditions
|
|
632
|
+
# This ensures user isn't deactivated between this check and password set
|
|
633
|
+
user = await uow.users.get_by_id(user_id, for_update=True)
|
|
615
634
|
|
|
616
635
|
if user is None:
|
|
617
636
|
raise UserNotFoundError()
|
|
@@ -326,6 +326,17 @@ class IdentityPlanKitConfig(BaseSettings):
|
|
|
326
326
|
"Allows clients to safely retry refresh requests within this window.",
|
|
327
327
|
)
|
|
328
328
|
|
|
329
|
+
# Quota consumption idempotency
|
|
330
|
+
quota_idempotency_ttl_seconds: int = Field(
|
|
331
|
+
default=60,
|
|
332
|
+
ge=0,
|
|
333
|
+
le=300,
|
|
334
|
+
description="TTL for quota consumption idempotency cache in seconds. "
|
|
335
|
+
"When an idempotency_key is provided to check_and_consume_quota(), "
|
|
336
|
+
"duplicate requests within this window return the cached result "
|
|
337
|
+
"instead of consuming quota again. Set to 0 to disable idempotency.",
|
|
338
|
+
)
|
|
339
|
+
|
|
329
340
|
# Rate limiting
|
|
330
341
|
rate_limit_login: str = Field(
|
|
331
342
|
default="20/minute",
|
|
@@ -343,6 +354,14 @@ class IdentityPlanKitConfig(BaseSettings):
|
|
|
343
354
|
default="10/minute",
|
|
344
355
|
description="Rate limit for logout endpoint",
|
|
345
356
|
)
|
|
357
|
+
rate_limit_profile: str = Field(
|
|
358
|
+
default="60/minute",
|
|
359
|
+
description="Rate limit for profile endpoints (/auth/me, /auth/profile)",
|
|
360
|
+
)
|
|
361
|
+
rate_limit_plans: str = Field(
|
|
362
|
+
default="60/minute",
|
|
363
|
+
description="Rate limit for plan endpoints (/plans)",
|
|
364
|
+
)
|
|
346
365
|
|
|
347
366
|
# Proxy settings
|
|
348
367
|
trust_proxy_headers: bool = Field(
|