Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users/<id>/permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts
635 lines
25 KiB
Python
635 lines
25 KiB
Python
import secrets
|
|
from datetime import datetime, timedelta
|
|
|
|
from django.contrib.auth import authenticate, get_user_model, login, logout
|
|
from django.utils import timezone
|
|
from rest_framework import mixins, status, viewsets
|
|
from rest_framework.decorators import action
|
|
from rest_framework.exceptions import (
|
|
AuthenticationFailed,
|
|
NotAuthenticated,
|
|
NotFound,
|
|
PermissionDenied,
|
|
ValidationError,
|
|
)
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
|
|
from infrasynth.shared.exceptions import NotFoundError
|
|
from infrasynth.shared.settings_utils import get_setting
|
|
from infrasynth.tenancy.services import TenantService
|
|
|
|
from .altcha.services import ALTCHAService
|
|
from .models import APIKey, Grant, Revoke, Role, TwoFactorConfig
|
|
from .permissions import IsAuthenticatedAndPermitted
|
|
from .serializers import (
|
|
APIKeySerializer,
|
|
GrantSerializer,
|
|
LoginSerializer,
|
|
RevokeSerializer,
|
|
RoleSerializer,
|
|
)
|
|
from .services import AuthorizationService
|
|
from .signals import (
|
|
api_key_created,
|
|
api_key_rotated,
|
|
login_failed,
|
|
two_factor_verified,
|
|
user_logged_in,
|
|
user_logged_out,
|
|
)
|
|
from .throttling import LoginAttemptGuard
|
|
from .two_factor.services import RecoveryCodeService, TOTPService
|
|
from .two_factor.utils import generate_pre_auth_token
|
|
|
|
UserModel = get_user_model()
|
|
|
|
|
|
class _AuthSupport:
|
|
"""Cookie/JWT/2FA helpers shared by the auth and 2FA viewsets."""
|
|
|
|
@staticmethod
|
|
def _mint_tokens(user, tenant):
|
|
refresh = RefreshToken.for_user(user)
|
|
if tenant is not None:
|
|
refresh["tenant"] = str(tenant.pk)
|
|
return str(refresh.access_token), str(refresh)
|
|
|
|
def _cookie_config(self):
|
|
return {
|
|
"secure": get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True),
|
|
"httponly": get_setting("INFRASYNTH_SECURITY", "COOKIE_HTTPONLY", True),
|
|
"samesite": get_setting("INFRASYNTH_SECURITY", "COOKIE_SAMESITE", "Lax"),
|
|
"access_cookie": get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token"),
|
|
"refresh_cookie": get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token"),
|
|
"pre_auth_cookie": get_setting("INFRASYNTH_SECURITY", "PRE_AUTH_COOKIE_NAME", "pre_auth_token"),
|
|
}
|
|
|
|
def _encrypt(self, value):
|
|
from infrasynth.shared.crypto import encrypt as crypto_encrypt
|
|
|
|
try:
|
|
return crypto_encrypt(value)
|
|
except Exception:
|
|
return value
|
|
|
|
def _decrypt(self, value):
|
|
from infrasynth.shared.crypto import decrypt as crypto_decrypt
|
|
|
|
try:
|
|
return crypto_decrypt(value)
|
|
except Exception:
|
|
return value
|
|
|
|
def _set_auth_cookies(self, response, access_token, refresh_token):
|
|
cfg = self._cookie_config()
|
|
response.set_cookie(
|
|
cfg["access_cookie"],
|
|
self._encrypt(access_token),
|
|
max_age=3600,
|
|
secure=cfg["secure"],
|
|
httponly=cfg["httponly"],
|
|
samesite=cfg["samesite"],
|
|
path="/",
|
|
)
|
|
response.set_cookie(
|
|
cfg["refresh_cookie"],
|
|
self._encrypt(refresh_token),
|
|
max_age=86400 * 7,
|
|
secure=cfg["secure"],
|
|
httponly=cfg["httponly"],
|
|
samesite=cfg["samesite"],
|
|
path="/",
|
|
)
|
|
|
|
def _set_refresh_cookie(self, response, refresh_token):
|
|
cfg = self._cookie_config()
|
|
response.set_cookie(
|
|
cfg["refresh_cookie"],
|
|
self._encrypt(refresh_token),
|
|
max_age=86400 * 7,
|
|
secure=cfg["secure"],
|
|
httponly=cfg["httponly"],
|
|
samesite=cfg["samesite"],
|
|
path="/",
|
|
)
|
|
|
|
def _user_from_refresh_cookie(self, request):
|
|
raw = request.COOKIES.get(get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token"))
|
|
if not raw:
|
|
return None
|
|
try:
|
|
refresh = RefreshToken(self._decrypt(raw)) # type: ignore[arg-type]
|
|
user_id = refresh.get("user_id")
|
|
except Exception:
|
|
return None
|
|
return UserModel.objects.filter(pk=user_id).first()
|
|
|
|
def _complete_login(self, request, user):
|
|
"""Issues tenant-bound tokens or a workspace-picker challenge."""
|
|
memberships = TenantService().get_active_memberships(user)
|
|
if len(memberships) == 1:
|
|
tenant = memberships[0].tenant
|
|
access, refresh = self._mint_tokens(user, tenant)
|
|
response = Response({"detail": "Login successful.", "tenant": str(tenant.pk)})
|
|
self._set_auth_cookies(response, access, refresh)
|
|
return response
|
|
if len(memberships) > 1:
|
|
_, refresh = self._mint_tokens(user, None)
|
|
response = Response(
|
|
{
|
|
"detail": "Select a workspace.",
|
|
"workspaces": [
|
|
{"id": str(m.tenant.pk), "slug": m.tenant.slug, "name": m.tenant.name} for m in memberships
|
|
],
|
|
}
|
|
)
|
|
self._set_refresh_cookie(response, refresh)
|
|
return response
|
|
raise PermissionDenied("No active workspace membership for this user.")
|
|
|
|
@staticmethod
|
|
def _requires_two_factor(user):
|
|
return TwoFactorConfig.objects.filter(user=user, is_enabled=True, is_configured=True).first()
|
|
|
|
def _start_pre_auth(self, request, user, config):
|
|
"""Stores the pre-auth session and cookie; returns the challenge response."""
|
|
token = generate_pre_auth_token()
|
|
request.session["_2fa_user_id"] = user.pk
|
|
request.session["_2fa_pre_auth_token"] = token
|
|
request.session["_2fa_started_at"] = timezone.now().isoformat()
|
|
cfg = self._cookie_config()
|
|
response = Response(
|
|
{
|
|
"detail": "Two-factor authentication required.",
|
|
"requires2fa": True,
|
|
"method": config.method,
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
lifetime = int(get_setting("INFRASYNTH_SECURITY", "PRE_AUTH_TOKEN_LIFETIME_MINUTES", 5))
|
|
response.set_cookie(
|
|
cfg["pre_auth_cookie"],
|
|
token,
|
|
max_age=lifetime * 60,
|
|
secure=cfg["secure"],
|
|
httponly=cfg["httponly"],
|
|
samesite=cfg["samesite"],
|
|
path="/",
|
|
)
|
|
return response
|
|
|
|
@staticmethod
|
|
def _valid_pre_auth(request) -> bool:
|
|
user_id = request.session.get("_2fa_user_id")
|
|
token = request.session.get("_2fa_pre_auth_token")
|
|
if not user_id or not token:
|
|
return False
|
|
started = request.session.get("_2fa_started_at")
|
|
lifetime = int(get_setting("INFRASYNTH_SECURITY", "PRE_AUTH_TOKEN_LIFETIME_MINUTES", 5))
|
|
if started:
|
|
try:
|
|
started_at = datetime.fromisoformat(started)
|
|
except ValueError:
|
|
return False
|
|
if timezone.now() - started_at > timedelta(minutes=lifetime):
|
|
return False
|
|
return True
|
|
|
|
def _finish_two_factor(self, request, user):
|
|
login(request, user)
|
|
request.session["_2fa_verified"] = True
|
|
request.session.pop("_2fa_user_id", None)
|
|
request.session.pop("_2fa_pre_auth_token", None)
|
|
request.session.pop("_2fa_started_at", None)
|
|
two_factor_verified.send(sender=self.__class__, user=user, method="totp")
|
|
return self._complete_login(request, user)
|
|
|
|
|
|
class AuthViewSet(_AuthSupport, viewsets.GenericViewSet):
|
|
permission_classes = [AllowAny]
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def login(self, request):
|
|
serializer = LoginSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
credential = serializer.validated_data["username"]
|
|
guard = LoginAttemptGuard()
|
|
guard.check(request, credential)
|
|
|
|
user = authenticate(
|
|
request,
|
|
username=credential,
|
|
password=serializer.validated_data["password"],
|
|
)
|
|
if not user:
|
|
guard.record_failure(request, credential)
|
|
login_failed.send(
|
|
sender=self.__class__,
|
|
credentials_key=credential,
|
|
ip=request.META.get("REMOTE_ADDR"),
|
|
reason="invalid_credentials",
|
|
)
|
|
raise AuthenticationFailed("Invalid credentials.")
|
|
if not user.is_active:
|
|
raise AuthenticationFailed("User is disabled.")
|
|
|
|
guard.clear(request, credential)
|
|
|
|
if get_setting("INFRASYNTH_SECURITY", "ALTCHA_PROTECT_LOGIN", False):
|
|
altcha_ok = ALTCHAService().verify(
|
|
request.data.get("altchaChallengeId") or request.data.get("altcha_challenge_id"),
|
|
request.data.get("altchaSolution") or request.data.get("altcha_solution"),
|
|
request.data.get("altchaNumber") or request.data.get("altcha_number"),
|
|
)
|
|
if not altcha_ok:
|
|
raise ValidationError({"altcha": "A valid ALTCHA proof-of-work solution is required."})
|
|
|
|
config = self._requires_two_factor(user)
|
|
if config is not None:
|
|
return self._start_pre_auth(request, user, config)
|
|
|
|
login(request, user)
|
|
response = self._complete_login(request, user)
|
|
user_logged_in.send(
|
|
sender=self.__class__,
|
|
user=user,
|
|
ip=request.META.get("REMOTE_ADDR"),
|
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
|
)
|
|
return response
|
|
|
|
@action(detail=False, methods=["post"], url_path="select-workspace")
|
|
def select_workspace(self, request):
|
|
user = self._user_from_refresh_cookie(request)
|
|
if user is None:
|
|
raise AuthenticationFailed("No pending workspace selection.")
|
|
tenant_id = request.data.get("tenantId") or request.data.get("tenant_id")
|
|
if not tenant_id:
|
|
raise ValidationError({"tenantId": "This field is required."})
|
|
try:
|
|
tenant = TenantService().select_tenant(user, tenant_id)
|
|
except NotFoundError as exc:
|
|
raise NotFound(str(exc)) from exc
|
|
access, refresh = self._mint_tokens(user, tenant)
|
|
response = Response({"detail": "Workspace selected.", "tenant": str(tenant.pk)})
|
|
self._set_auth_cookies(response, access, refresh)
|
|
return response
|
|
|
|
@action(detail=False, methods=["post"], url_path="switch-workspace")
|
|
def switch_workspace(self, request):
|
|
if not request.user or not request.user.is_authenticated:
|
|
raise NotAuthenticated("Authentication required.")
|
|
if not get_setting("INFRASYNTH_SECURITY", "ENABLE_WORKSPACE_SWITCHING", True):
|
|
raise NotFound("Workspace switching is disabled.")
|
|
tenant_id = request.data.get("tenantId") or request.data.get("tenant_id")
|
|
if not tenant_id:
|
|
raise ValidationError({"tenantId": "This field is required."})
|
|
from infrasynth.tenancy.context import get_current_tenant
|
|
|
|
previous = get_current_tenant()
|
|
try:
|
|
tenant = TenantService().switch_tenant(
|
|
request.user,
|
|
tenant_id,
|
|
previous_tenant_id=getattr(previous, "pk", None),
|
|
)
|
|
except NotFoundError as exc:
|
|
raise NotFound(str(exc)) from exc
|
|
access, refresh = self._mint_tokens(request.user, tenant)
|
|
response = Response({"detail": "Workspace switched.", "tenant": str(tenant.pk)})
|
|
self._set_auth_cookies(response, access, refresh)
|
|
return response
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def logout(self, request):
|
|
logout(request)
|
|
cfg = self._cookie_config()
|
|
response = Response({"detail": "Logout successful."})
|
|
response.delete_cookie(cfg["access_cookie"], path="/")
|
|
response.delete_cookie(cfg["refresh_cookie"], path="/")
|
|
response.delete_cookie(cfg["pre_auth_cookie"], path="/")
|
|
user_logged_out.send(sender=self.__class__, user=getattr(request, "user", None))
|
|
return response
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def refresh(self, request):
|
|
cfg = self._cookie_config()
|
|
raw_refresh = request.COOKIES.get(cfg["refresh_cookie"])
|
|
if not raw_refresh:
|
|
raise AuthenticationFailed("Refresh token not found.")
|
|
|
|
try:
|
|
refresh = RefreshToken(self._decrypt(raw_refresh)) # type: ignore[arg-type]
|
|
access_token = str(refresh.access_token)
|
|
if get_setting("INFRASYNTH_SECURITY", "ROTATE_REFRESH_TOKENS", True):
|
|
new_refresh = str(refresh)
|
|
if get_setting("INFRASYNTH_SECURITY", "BLACKLIST_AFTER_ROTATION", True):
|
|
from rest_framework_simplejwt.exceptions import TokenError
|
|
|
|
try:
|
|
refresh.blacklist()
|
|
except (AttributeError, TokenError): # blacklist app not installed
|
|
pass
|
|
else:
|
|
new_refresh = raw_refresh
|
|
except Exception:
|
|
raise AuthenticationFailed("Invalid or expired refresh token.")
|
|
|
|
response = Response({"detail": "Token refreshed."})
|
|
self._set_auth_cookies(response, access_token, new_refresh)
|
|
return response
|
|
|
|
@action(detail=False, methods=["get"])
|
|
def check(self, request):
|
|
if not request.user or not request.user.is_authenticated:
|
|
return Response({"authenticated": False}, status=status.HTTP_401_UNAUTHORIZED)
|
|
authz = AuthorizationService()
|
|
effective_permissions = list(authz.get_effective_permissions(request.user))
|
|
user = request.user
|
|
from infrasynth.tenancy.context import get_current_tenant
|
|
|
|
tenant = get_current_tenant()
|
|
return Response(
|
|
{
|
|
"id": user.pk,
|
|
"email": getattr(user, "email", ""),
|
|
"name": getattr(user, "get_full_name", lambda: str(user))(),
|
|
"effective_permissions": effective_permissions,
|
|
"tenant": str(tenant.pk) if tenant is not None else None,
|
|
}
|
|
)
|
|
|
|
|
|
class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get_permissions(self):
|
|
# ``verify``/``recovery`` are the pre-auth (not-yet-logged-in) steps; the
|
|
# router-less ``as_view`` mapping does not apply per-action overrides.
|
|
if self.action in ("verify", "recovery"):
|
|
return [AllowAny()]
|
|
return [IsAuthenticated()]
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def setup(self, request): # type: ignore[override]
|
|
totp = TOTPService()
|
|
secret = totp.generate_secret()
|
|
qr_base64 = totp.generate_qr_base64(secret, request.user.email)
|
|
provisioning_uri = totp.get_provisioning_uri(secret, request.user.email)
|
|
request.session["_2fa_setup_secret"] = secret
|
|
return Response(
|
|
{
|
|
"secret": secret,
|
|
"qr_base64": qr_base64,
|
|
"provisioning_uri": provisioning_uri,
|
|
}
|
|
)
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def verify_setup(self, request):
|
|
secret = request.session.get("_2fa_setup_secret")
|
|
if not secret:
|
|
raise AuthenticationFailed("No setup in progress.")
|
|
code = request.data.get("code")
|
|
if not code:
|
|
raise AuthenticationFailed("Code is required.")
|
|
totp = TOTPService()
|
|
if not totp.verify(secret, code):
|
|
raise AuthenticationFailed("Invalid code.")
|
|
rcs = RecoveryCodeService()
|
|
recovery_codes = rcs.generate_codes()
|
|
config, _ = TwoFactorConfig.objects.get_or_create(user=request.user)
|
|
config.is_enabled = True
|
|
config.is_configured = True
|
|
config.secret_key_encrypted = totp.encrypt_secret(secret)
|
|
config.recovery_codes_encrypted = rcs.encrypt_codes(recovery_codes)
|
|
config.save()
|
|
del request.session["_2fa_setup_secret"]
|
|
return Response({"recovery_codes": recovery_codes})
|
|
|
|
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
|
def verify(self, request):
|
|
if not self._valid_pre_auth(request):
|
|
raise AuthenticationFailed("No pre-auth session.")
|
|
user_id = request.session["_2fa_user_id"]
|
|
try:
|
|
config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True)
|
|
except TwoFactorConfig.DoesNotExist:
|
|
raise AuthenticationFailed("2FA not configured.")
|
|
code = request.data.get("code")
|
|
if not code:
|
|
raise AuthenticationFailed("Code is required.")
|
|
totp = TOTPService()
|
|
secret = totp.decrypt_secret(config.secret_key_encrypted)
|
|
if not totp.verify(secret, code):
|
|
raise AuthenticationFailed("Invalid code.")
|
|
return self._finish_two_factor(request, UserModel.objects.get(pk=user_id))
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def disable(self, request):
|
|
try:
|
|
config = TwoFactorConfig.objects.get(user=request.user)
|
|
config.is_enabled = False
|
|
config.is_configured = False
|
|
config.secret_key_encrypted = None
|
|
config.recovery_codes_encrypted = None
|
|
config.save()
|
|
except TwoFactorConfig.DoesNotExist:
|
|
pass
|
|
return Response({"detail": "2FA disabled."})
|
|
|
|
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
|
def recovery(self, request):
|
|
recovery_code = request.data.get("recovery_code")
|
|
if not recovery_code:
|
|
raise AuthenticationFailed("Recovery code is required.")
|
|
if not self._valid_pre_auth(request):
|
|
raise AuthenticationFailed("No pre-auth session.")
|
|
user_id = request.session["_2fa_user_id"]
|
|
try:
|
|
config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True)
|
|
except TwoFactorConfig.DoesNotExist:
|
|
raise AuthenticationFailed("2FA not configured.")
|
|
rcs = RecoveryCodeService()
|
|
if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted):
|
|
raise AuthenticationFailed("Invalid recovery code.")
|
|
updated = rcs.remove_used_code(recovery_code, config.recovery_codes_encrypted)
|
|
if updated:
|
|
config.recovery_codes_encrypted = updated
|
|
config.save(update_fields=["recovery_codes_encrypted"])
|
|
return self._finish_two_factor(request, UserModel.objects.get(pk=user_id))
|
|
|
|
|
|
class ALTCHAViewSet(viewsets.GenericViewSet):
|
|
permission_classes = [AllowAny]
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def challenge(self, request):
|
|
return Response(ALTCHAService().create_challenge())
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def verify(self, request):
|
|
result = ALTCHAService().verify(
|
|
request.data.get("challenge_id"),
|
|
request.data.get("solution"),
|
|
request.data.get("number"),
|
|
)
|
|
return Response({"verified": result})
|
|
|
|
|
|
class APIKeyViewSet(viewsets.ModelViewSet):
|
|
queryset = APIKey.objects.order_by("-id").all()
|
|
serializer_class = APIKeySerializer
|
|
permission_classes = [IsAuthenticatedAndPermitted]
|
|
required_permissions = ["security.manage_api_keys"]
|
|
|
|
def get_queryset(self):
|
|
return APIKey.objects.order_by("-id").all()
|
|
|
|
@staticmethod
|
|
def _generate_key():
|
|
from django.contrib.auth.hashers import make_password
|
|
|
|
prefix_chars = int(get_setting("INFRASYNTH_SECURITY", "API_KEY_PREFIX_LENGTH", 8))
|
|
hasher = get_setting("INFRASYNTH_SECURITY", "API_KEY_HASH_ALGORITHM", "pbkdf2_sha256")
|
|
prefix = secrets.token_hex(max(1, prefix_chars // 2))
|
|
secret = secrets.token_hex(32)
|
|
try:
|
|
key_hash = make_password(secret, hasher=hasher)
|
|
except ValueError: # hasher not registered in this project — use the default
|
|
key_hash = make_password(secret)
|
|
return prefix, secret, f"{prefix}.{secret}", key_hash
|
|
|
|
def _default_expiry(self):
|
|
days = int(get_setting("INFRASYNTH_SECURITY", "API_KEY_DEFAULT_EXPIRY_DAYS", 365))
|
|
if days <= 0:
|
|
return None
|
|
return timezone.now() + timedelta(days=days)
|
|
|
|
def perform_create(self, serializer):
|
|
prefix, _secret, full_key, key_hash = self._generate_key()
|
|
instance = serializer.save(
|
|
prefix=prefix,
|
|
key_hash=key_hash,
|
|
created_by=self.request.user,
|
|
expires_at=serializer.validated_data.get("expires_at") or self._default_expiry(),
|
|
)
|
|
instance.key = full_key
|
|
api_key_created.send(sender=APIKey, key_name=instance.name, created_by=self.request.user)
|
|
return instance
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def rotate(self, request, pk=None):
|
|
"""Issues a new secret for the key and deactivates the old one."""
|
|
old = self.get_object()
|
|
prefix, _secret, full_key, key_hash = self._generate_key()
|
|
new = APIKey.all_objects.create(
|
|
tenant=old.tenant,
|
|
name=old.name,
|
|
prefix=prefix,
|
|
key_hash=key_hash,
|
|
scopes=old.scopes,
|
|
created_by=request.user,
|
|
expires_at=old.expires_at or self._default_expiry(),
|
|
rotated_from=old,
|
|
)
|
|
old.is_active = False
|
|
old.save(update_fields=["is_active"])
|
|
api_key_rotated.send(sender=APIKey, key_name=new.name, rotated_by=request.user)
|
|
data = dict(APIKeySerializer(new).data)
|
|
data["key"] = full_key
|
|
return Response(data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class RoleViewSet(viewsets.ModelViewSet):
|
|
queryset = Role.objects.order_by("name").all()
|
|
serializer_class = RoleSerializer
|
|
permission_classes = [IsAuthenticatedAndPermitted]
|
|
required_permissions = ["security.manage_roles"]
|
|
lookup_field = "slug"
|
|
|
|
def get_queryset(self):
|
|
return Role.objects.order_by("name").all()
|
|
|
|
def perform_destroy(self, instance):
|
|
if instance.is_system:
|
|
raise PermissionDenied("System roles cannot be deleted.")
|
|
instance.delete()
|
|
|
|
|
|
class GrantViewSet(
|
|
mixins.CreateModelMixin,
|
|
mixins.ListModelMixin,
|
|
mixins.DestroyModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
queryset = Grant.objects.all()
|
|
serializer_class = GrantSerializer
|
|
permission_classes = [IsAuthenticatedAndPermitted]
|
|
required_permissions = ["security.manage_grants"]
|
|
|
|
def get_queryset(self):
|
|
return Grant.objects.select_related("user", "granted_by").order_by("-id").all()
|
|
|
|
def perform_create(self, serializer):
|
|
serializer.save(granted_by=self.request.user)
|
|
|
|
|
|
class RevokeViewSet(
|
|
mixins.CreateModelMixin,
|
|
mixins.ListModelMixin,
|
|
mixins.DestroyModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
queryset = Revoke.objects.all()
|
|
serializer_class = RevokeSerializer
|
|
permission_classes = [IsAuthenticatedAndPermitted]
|
|
required_permissions = ["security.manage_grants"]
|
|
|
|
def get_queryset(self):
|
|
return Revoke.objects.select_related("user", "revoked_by").order_by("-id").all()
|
|
|
|
def perform_create(self, serializer):
|
|
serializer.save(revoked_by=self.request.user)
|
|
|
|
|
|
class UserPermissionViewSet(viewsets.GenericViewSet):
|
|
"""Effective permissions and role assignment for a user (``/auth/users/<id>/…``)."""
|
|
|
|
permission_classes = [IsAuthenticatedAndPermitted]
|
|
queryset = UserModel.objects.all()
|
|
|
|
def initial(self, request, *args, **kwargs):
|
|
if request.method in ("PUT", "PATCH", "POST"):
|
|
self.required_permissions = ["security.manage_roles"]
|
|
else:
|
|
self.required_permissions = ["security.view_permissions"]
|
|
super().initial(request, *args, **kwargs)
|
|
|
|
def _get_user(self, pk):
|
|
user = UserModel.objects.filter(pk=pk).first()
|
|
if user is None:
|
|
raise NotFound("User not found.")
|
|
return user
|
|
|
|
@action(detail=True, methods=["get"], url_path="permissions")
|
|
def permissions(self, request, pk=None):
|
|
user = self._get_user(pk)
|
|
perms = sorted(AuthorizationService().get_effective_permissions(user))
|
|
return Response({"user": user.pk, "permissions": perms})
|
|
|
|
@action(detail=True, methods=["get", "put"], url_path="roles")
|
|
def roles(self, request, pk=None):
|
|
user = self._get_user(pk)
|
|
if request.method == "PUT":
|
|
slugs = request.data.get("roles", [])
|
|
if not isinstance(slugs, list):
|
|
raise ValidationError({"roles": "Expected a list of role slugs."})
|
|
resolved = list(Role.objects.filter(slug__in=slugs))
|
|
missing = set(slugs) - {role.slug for role in resolved}
|
|
if missing:
|
|
raise ValidationError({"roles": f"Unknown role(s): {', '.join(sorted(missing))}"})
|
|
user.roles.set(resolved)
|
|
return Response({"user": user.pk, "roles": list(user.roles.values_list("slug", flat=True))})
|