Coverage for app/routers/openstack_credentials.py: 96.88%
64 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""Per-user OpenStack credential management.
3All endpoints scope to the caller (`current_user`) — there is no
4`{user_id}` path parameter. This makes IDOR impossible by construction.
5The masked response never returns identifier or secret material.
6"""
7from __future__ import annotations
9from fastapi import APIRouter, Depends, HTTPException, Response, status
10from sqlalchemy.orm import Session
12from app.crud import deployments as crud_deployments
13from app.crud import locks as crud_locks
14from app.crud import openstack_credentials as crud_creds
15from app.database import get_db
16from app.models import User
17from app.schemas import (
18 OpenStackCredentialFromYaml,
19 OpenStackCredentialResponse,
20 OpenStackCredentialUpsert,
21)
22from app.services import clouds_yaml_parser, openstack_validator
23from app.utils.keycloak_auth import get_current_user_keycloak
25router = APIRouter()
28def _lock_state(db: Session, user_id) -> tuple[bool, int]:
29 n = crud_deployments.count_active_user_deployments(db, user_id)
30 return (n > 0, n)
33def _assert_unlocked(db: Session, user: User) -> None:
34 """Refuse credential mutation while the user has non-destroyed deployments."""
35 is_locked, n = _lock_state(db, user.userId)
36 if is_locked:
37 raise HTTPException(
38 status_code=status.HTTP_409_CONFLICT,
39 detail={
40 "reason": "openstack_credentials_locked",
41 "active_deployments": n,
42 },
43 )
46def _to_response(
47 row,
48 *,
49 has_credential: bool,
50 is_locked: bool,
51 active_deployments: int,
52) -> OpenStackCredentialResponse:
53 if row is None:
54 return OpenStackCredentialResponse(
55 has_credential=False,
56 is_locked=is_locked,
57 active_deployments=active_deployments,
58 )
59 return OpenStackCredentialResponse(
60 auth_type=row.auth_type,
61 auth_url=row.auth_url,
62 region_name=row.region_name,
63 interface=row.interface,
64 identity_api_version=row.identity_api_version,
65 project_id=row.project_id,
66 project_name=row.project_name,
67 user_domain_name=row.user_domain_name,
68 project_domain_name=row.project_domain_name,
69 has_credential=has_credential,
70 last_validated_at=row.last_validated_at,
71 last_validation_error=row.last_validation_error,
72 created_at=row.created_at,
73 updated_at=row.updated_at,
74 is_locked=is_locked,
75 active_deployments=active_deployments,
76 )
79@router.get(
80 "/me/openstack-credentials",
81 response_model=OpenStackCredentialResponse,
82)
83def get_my_credentials(
84 db: Session = Depends(get_db),
85 current_user: User = Depends(get_current_user_keycloak),
86):
87 """Always 200 — returns has_credential=False when none configured.
89 Lock state is included regardless so the frontend can render the
90 correct guard UI without a second request.
91 """
92 row = crud_creds.get_for_user(db, current_user.userId)
93 is_locked, n = _lock_state(db, current_user.userId)
94 return _to_response(
95 row,
96 has_credential=row is not None,
97 is_locked=is_locked,
98 active_deployments=n,
99 )
102@router.put(
103 "/me/openstack-credentials",
104 response_model=OpenStackCredentialResponse,
105)
106def upsert_my_credentials(
107 payload: OpenStackCredentialUpsert,
108 db: Session = Depends(get_db),
109 current_user: User = Depends(get_current_user_keycloak),
110):
111 """Auto-validates against Keystone before persisting.
113 On a successful authorize we record `last_validated_at`. On failure
114 we still persist the row (so the user can fix it via the UI) and
115 record the human-readable error message.
116 """
117 crud_locks.acquire_user_xact_lock(db, current_user.userId)
118 _assert_unlocked(db, current_user)
119 result = openstack_validator.validate(payload)
120 row = crud_creds.upsert(db, current_user.userId, payload, result)
121 is_locked, n = _lock_state(db, current_user.userId)
122 return _to_response(row, has_credential=True, is_locked=is_locked, active_deployments=n)
125@router.put(
126 "/me/openstack-credentials/from-yaml",
127 response_model=OpenStackCredentialResponse,
128)
129def upsert_my_credentials_from_yaml(
130 body: OpenStackCredentialFromYaml,
131 db: Session = Depends(get_db),
132 current_user: User = Depends(get_current_user_keycloak),
133):
134 crud_locks.acquire_user_xact_lock(db, current_user.userId)
135 _assert_unlocked(db, current_user)
136 payload = clouds_yaml_parser.parse(body.clouds_yaml, body.cloud_name)
137 result = openstack_validator.validate(payload)
138 row = crud_creds.upsert(db, current_user.userId, payload, result)
139 is_locked, n = _lock_state(db, current_user.userId)
140 return _to_response(row, has_credential=True, is_locked=is_locked, active_deployments=n)
143@router.post(
144 "/me/openstack-credentials/test",
145 response_model=OpenStackCredentialResponse,
146)
147def test_my_credentials(
148 db: Session = Depends(get_db),
149 current_user: User = Depends(get_current_user_keycloak),
150):
151 """Re-authorize the stored credential and refresh validation metadata.
153 Allowed even while locked — purely read-only against Keystone.
154 """
155 row = crud_creds.get_for_user(db, current_user.userId)
156 if row is None:
157 raise HTTPException(
158 status_code=status.HTTP_404_NOT_FOUND,
159 detail="No OpenStack credentials configured",
160 )
161 plaintext = crud_creds.get_decrypted_for_backend(db, current_user.userId)
162 payload = OpenStackCredentialUpsert(
163 auth_type=row.auth_type,
164 auth_url=row.auth_url,
165 region_name=row.region_name,
166 interface=row.interface,
167 identity_api_version=row.identity_api_version,
168 project_id=row.project_id,
169 project_name=row.project_name,
170 user_domain_name=row.user_domain_name,
171 project_domain_name=row.project_domain_name,
172 identifier=plaintext["identifier"],
173 secret=plaintext["secret"],
174 )
175 result = openstack_validator.validate(payload)
176 row = crud_creds.stamp_validation(db, row, result)
177 is_locked, n = _lock_state(db, current_user.userId)
178 return _to_response(row, has_credential=True, is_locked=is_locked, active_deployments=n)
181@router.delete(
182 "/me/openstack-credentials",
183 status_code=status.HTTP_204_NO_CONTENT,
184 response_class=Response,
185)
186def delete_my_credentials(
187 db: Session = Depends(get_db),
188 current_user: User = Depends(get_current_user_keycloak),
189):
190 crud_locks.acquire_user_xact_lock(db, current_user.userId)
191 _assert_unlocked(db, current_user)
192 deleted = crud_creds.delete(db, current_user.userId)
193 if not deleted:
194 raise HTTPException(
195 status_code=status.HTTP_404_NOT_FOUND,
196 detail="No OpenStack credentials configured",
197 )
198 return Response(status_code=status.HTTP_204_NO_CONTENT)