Skip to content

Commit fe4e92f

Browse files
AkhileshNegipriyanshu6238Akhilesh Negi
authored
Credentials: Add support for org credentials (#179)
* Add provider column to credential table and update API for provider-specific credentials - Introduced a new column 'provider' in the credential table to support multiple credential providers. - Updated API routes to handle provider-specific credential operations, including creation, retrieval, updating, and deletion. - Enhanced validation for provider credentials and added support for multiple providers in the data model. - Refactored existing credential handling functions to accommodate the new structure and improve error handling. - Ensured backward compatibility by maintaining existing functionality while expanding capabilities. * Refactor credential tests to streamline organization and credential creation, enhance readability, and ensure proper handling of provider-specific data. * Add soft delete functionality for credentials and update tests to verify deletion * Refactor credential handling to improve error management and ensure proper checks for credential existence; update tests for accuracy in response validation. * Enhance credential update handling with organization existence checks and improved error responses; update tests for accurate status codes and messages. * Enhance credential update handling with validation for provider and credential fields; improve error responses for organization checks and unexpected exceptions. * Refactor credential * Fix down_revision reference in migration script for provider column addition * added provider test in core * refactor test_provider.py * updates * cherry picking from other PR * using encrypt/decrypt * cleanups * decrypting credentials * added support for per project per organization * few more steps forward to per org per project per provider * encrypting the entire credentials column * added testcases * updating threads codes * updating time * updating time * reverting changes * update migration * updated testcases * updated credentials testcases * using langfuse from creds * few cleanups * cleanup & refactoring * refactor security * getting rid of redundant str() * using now() * cleanup few testcases and code --------- Co-authored-by: Priyanshu singh <111607560+PriyanSingh@users.noreply.github.com> Co-authored-by: Akhilesh Negi <akhileshnegi@Akhileshs-MacBook-Pro.local>
1 parent a3be786 commit fe4e92f

18 files changed

+1342
-428
lines changed

backend/app/alembic/versions/543f97951bd0_add_credential_table.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""add credetial table
1+
"""add credential table
22
33
Revision ID: 543f97951bd0
44
Revises: 8d7a05fd0ad4
@@ -22,7 +22,7 @@ def upgrade():
2222
"credential",
2323
sa.Column("id", sa.Integer(), nullable=False),
2424
sa.Column("is_active", sa.Boolean(), nullable=False),
25-
sa.Column("credential", sa.JSON(), nullable=True),
25+
sa.Column("credential", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
2626
sa.Column("organization_id", sa.Integer(), nullable=False),
2727
sa.Column("inserted_at", sa.DateTime(), nullable=True),
2828
sa.Column("updated_at", sa.DateTime(), nullable=True),
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Added provider column to the credential table
2+
3+
Revision ID: 904ed70e7dab
4+
Revises: 79e47bc3aac6
5+
Create Date: 2025-05-10 11:13:17.868238
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
revision = "904ed70e7dab"
14+
down_revision = "79e47bc3aac6"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# Add new columns to credential table
21+
op.add_column(
22+
"credential",
23+
sa.Column("provider", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
24+
)
25+
op.add_column("credential", sa.Column("project_id", sa.Integer(), nullable=True))
26+
27+
# Create indexes and constraints
28+
op.create_index(
29+
op.f("ix_credential_provider"), "credential", ["provider"], unique=False
30+
)
31+
32+
# Drop existing foreign keys
33+
op.drop_constraint(
34+
"credential_organization_id_fkey", "credential", type_="foreignkey"
35+
)
36+
op.drop_constraint("project_organization_id_fkey", "project", type_="foreignkey")
37+
38+
# Create all foreign keys together
39+
op.create_foreign_key(
40+
"credential_organization_id_fkey",
41+
"credential",
42+
"organization",
43+
["organization_id"],
44+
["id"],
45+
ondelete="CASCADE",
46+
)
47+
op.create_foreign_key(
48+
None,
49+
"project",
50+
"organization",
51+
["organization_id"],
52+
["id"],
53+
)
54+
op.create_foreign_key(
55+
"credential_project_id_fkey",
56+
"credential",
57+
"project",
58+
["project_id"],
59+
["id"],
60+
ondelete="SET NULL",
61+
)
62+
63+
64+
def downgrade():
65+
# Drop project_id foreign key and column
66+
op.drop_constraint("credential_project_id_fkey", "credential", type_="foreignkey")
67+
op.drop_column("credential", "project_id")
68+
69+
# Drop existing foreign keys
70+
op.drop_constraint(None, "project", type_="foreignkey")
71+
op.drop_constraint(
72+
"credential_organization_id_fkey", "credential", type_="foreignkey"
73+
)
74+
75+
# Create all foreign keys together
76+
op.create_foreign_key(
77+
"project_organization_id_fkey",
78+
"project",
79+
"organization",
80+
["organization_id"],
81+
["id"],
82+
ondelete="CASCADE",
83+
)
84+
op.create_foreign_key(
85+
"credential_organization_id_fkey",
86+
"credential",
87+
"organization",
88+
["organization_id"],
89+
["id"],
90+
)
91+
92+
op.drop_index(op.f("ix_credential_provider"), table_name="credential")
93+
op.drop_column("credential", "provider")

backend/app/api/routes/credentials.py

Lines changed: 142 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,147 @@
1+
from typing import List
2+
13
from fastapi import APIRouter, Depends, HTTPException
4+
from sqlalchemy.exc import IntegrityError
5+
26
from app.api.deps import SessionDep, get_current_active_superuser
37
from app.crud.credentials import (
48
get_creds_by_org,
5-
get_key_by_org,
9+
get_provider_credential,
610
remove_creds_for_org,
711
set_creds_for_org,
812
update_creds_for_org,
13+
remove_provider_credential,
914
)
1015
from app.models import CredsCreate, CredsPublic, CredsUpdate
16+
from app.models.organization import Organization
17+
from app.models.project import Project
1118
from app.utils import APIResponse
12-
from datetime import datetime
19+
from app.core.providers import validate_provider
1320

1421
router = APIRouter(prefix="/credentials", tags=["credentials"])
1522

1623

1724
@router.post(
1825
"/",
1926
dependencies=[Depends(get_current_active_superuser)],
20-
response_model=APIResponse[CredsPublic],
27+
response_model=APIResponse[List[CredsPublic]],
28+
summary="Create new credentials for an organization and project",
29+
description="Creates new credentials for a specific organization and project combination. This endpoint requires superuser privileges. Each organization can have different credentials for different providers and projects. Only one credential per provider is allowed per organization-project combination.",
2130
)
2231
def create_new_credential(*, session: SessionDep, creds_in: CredsCreate):
23-
new_creds = None
2432
try:
25-
existing_creds = get_creds_by_org(
26-
session=session, org_id=creds_in.organization_id
27-
)
28-
if not existing_creds:
29-
new_creds = set_creds_for_org(session=session, creds_add=creds_in)
33+
# Check if organization exists
34+
organization = session.get(Organization, creds_in.organization_id)
35+
if not organization:
36+
raise HTTPException(status_code=404, detail="Organization not found")
37+
38+
# Check if project exists if project_id is provided
39+
if creds_in.project_id:
40+
project = session.get(Project, creds_in.project_id)
41+
if not project:
42+
raise HTTPException(status_code=404, detail="Project not found")
43+
if project.organization_id != creds_in.organization_id:
44+
raise HTTPException(
45+
status_code=400,
46+
detail="Project does not belong to the specified organization",
47+
)
48+
49+
# Check for existing credentials for each provider
50+
for provider in creds_in.credential.keys():
51+
existing_cred = get_provider_credential(
52+
session=session,
53+
org_id=creds_in.organization_id,
54+
provider=provider,
55+
project_id=creds_in.project_id,
56+
)
57+
if existing_cred:
58+
raise HTTPException(
59+
status_code=400,
60+
detail=f"Credentials for provider '{provider}' already exist for this organization and project combination",
61+
)
62+
63+
# Create new credentials
64+
new_creds = set_creds_for_org(session=session, creds_add=creds_in)
65+
if not new_creds:
66+
raise HTTPException(status_code=500, detail="Failed to create credentials")
67+
return APIResponse.success_response([cred.to_public() for cred in new_creds])
68+
except ValueError as e:
69+
raise HTTPException(status_code=404, detail=str(e))
3070
except Exception as e:
3171
raise HTTPException(
3272
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
3373
)
3474

35-
# Ensure inserted_at is set during creation
36-
new_creds.inserted_at = datetime.utcnow()
37-
38-
return APIResponse.success_response(new_creds)
39-
4075

4176
@router.get(
4277
"/{org_id}",
4378
dependencies=[Depends(get_current_active_superuser)],
44-
response_model=APIResponse[CredsPublic],
79+
response_model=APIResponse[List[CredsPublic]],
80+
summary="Get all credentials for an organization and project",
81+
description="Retrieves all provider credentials associated with a specific organization and project combination. If project_id is not provided, returns credentials for the organization level. This endpoint requires superuser privileges.",
4582
)
46-
def read_credential(*, session: SessionDep, org_id: int):
47-
try:
48-
creds = get_creds_by_org(session=session, org_id=org_id)
49-
except Exception as e:
50-
raise HTTPException(
51-
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
52-
)
53-
54-
if creds is None:
83+
def read_credential(*, session: SessionDep, org_id: int, project_id: int | None = None):
84+
creds = get_creds_by_org(session=session, org_id=org_id, project_id=project_id)
85+
if not creds:
5586
raise HTTPException(status_code=404, detail="Credentials not found")
56-
57-
return APIResponse.success_response(creds)
87+
return APIResponse.success_response([cred.to_public() for cred in creds])
5888

5989

6090
@router.get(
61-
"/{org_id}/api-key",
91+
"/{org_id}/{provider}",
6292
dependencies=[Depends(get_current_active_superuser)],
6393
response_model=APIResponse[dict],
94+
summary="Get specific provider credentials for an organization and project",
95+
description="Retrieves credentials for a specific provider (e.g., 'openai', 'anthropic') for a given organization and project combination. If project_id is not provided, returns organization-level credentials. This endpoint requires superuser privileges.",
6496
)
65-
def read_api_key(*, session: SessionDep, org_id: int):
66-
try:
67-
api_key = get_key_by_org(session=session, org_id=org_id)
68-
except Exception as e:
69-
raise HTTPException(
70-
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
71-
)
72-
73-
if api_key is None:
74-
raise HTTPException(status_code=404, detail="API key not found")
75-
76-
return APIResponse.success_response({"api_key": api_key})
97+
def read_provider_credential(
98+
*, session: SessionDep, org_id: int, provider: str, project_id: int | None = None
99+
):
100+
provider_enum = validate_provider(provider)
101+
provider_creds = get_provider_credential(
102+
session=session,
103+
org_id=org_id,
104+
provider=provider_enum,
105+
project_id=project_id,
106+
)
107+
if provider_creds is None:
108+
raise HTTPException(status_code=404, detail="Provider credentials not found")
109+
return APIResponse.success_response(provider_creds)
77110

78111

79112
@router.patch(
80113
"/{org_id}",
81114
dependencies=[Depends(get_current_active_superuser)],
82-
response_model=APIResponse[CredsPublic],
115+
response_model=APIResponse[List[CredsPublic]],
116+
summary="Update organization and project credentials",
117+
description="Updates credentials for a specific organization and project combination. Can update specific provider credentials or add new providers. If project_id is provided in the update, credentials will be moved to that project. This endpoint requires superuser privileges.",
83118
)
84119
def update_credential(*, session: SessionDep, org_id: int, creds_in: CredsUpdate):
85120
try:
121+
if not creds_in or not creds_in.provider or not creds_in.credential:
122+
raise HTTPException(
123+
status_code=400, detail="Provider and credential must be provided"
124+
)
125+
organization = session.get(Organization, org_id)
126+
if not organization:
127+
raise HTTPException(status_code=404, detail="Organization not found")
86128
updated_creds = update_creds_for_org(
87129
session=session, org_id=org_id, creds_in=creds_in
88130
)
89-
90-
updated_creds.updated_at = datetime.utcnow()
91-
92-
return APIResponse.success_response(updated_creds)
131+
if not updated_creds:
132+
raise HTTPException(status_code=404, detail="Failed to update credentials")
133+
return APIResponse.success_response(
134+
[cred.to_public() for cred in updated_creds]
135+
)
136+
except IntegrityError as e:
137+
if "ForeignKeyViolation" in str(e):
138+
raise HTTPException(
139+
status_code=400,
140+
detail="Invalid organization ID. Ensure the organization exists before updating credentials.",
141+
)
142+
raise HTTPException(
143+
status_code=500, detail=f"An unexpected database error occurred: {str(e)}"
144+
)
93145
except ValueError as e:
94146
raise HTTPException(status_code=404, detail=str(e))
95147
except Exception as e:
@@ -98,30 +150,61 @@ def update_credential(*, session: SessionDep, org_id: int, creds_in: CredsUpdate
98150
)
99151

100152

101-
from fastapi import HTTPException, Depends
102-
from app.crud.credentials import remove_creds_for_org
103-
from app.utils import APIResponse
104-
from app.api.deps import SessionDep, get_current_active_superuser
105-
106-
107153
@router.delete(
108-
"/{org_id}/api-key",
154+
"/{org_id}/{provider}",
109155
dependencies=[Depends(get_current_active_superuser)],
110156
response_model=APIResponse[dict],
157+
summary="Delete specific provider credentials for an organization and project",
158+
description="Removes credentials for a specific provider while keeping other provider credentials intact. If project_id is provided, only removes credentials for that project. This endpoint requires superuser privileges.",
111159
)
112-
def delete_credential(*, session: SessionDep, org_id: int):
160+
def delete_provider_credential(
161+
*, session: SessionDep, org_id: int, provider: str, project_id: int | None = None
162+
):
113163
try:
114-
creds = remove_creds_for_org(session=session, org_id=org_id)
164+
provider_enum = validate_provider(provider)
165+
updated_creds = remove_provider_credential(
166+
session=session,
167+
org_id=org_id,
168+
provider=provider_enum,
169+
project_id=project_id,
170+
)
171+
if not updated_creds:
172+
raise HTTPException(
173+
status_code=404, detail="Provider credentials not found"
174+
)
175+
return APIResponse.success_response(
176+
{"message": "Provider credentials removed successfully"}
177+
)
178+
except ValueError:
179+
raise HTTPException(status_code=404, detail="Provider credentials not found")
115180
except Exception as e:
116181
raise HTTPException(
117182
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
118183
)
119184

120-
if creds is None:
185+
186+
@router.delete(
187+
"/{org_id}",
188+
dependencies=[Depends(get_current_active_superuser)],
189+
response_model=APIResponse[dict],
190+
summary="Delete all credentials for an organization and project",
191+
description="Removes all credentials for a specific organization and project combination. If project_id is provided, only removes credentials for that project. This is a soft delete operation that marks credentials as inactive. This endpoint requires superuser privileges.",
192+
)
193+
def delete_all_credentials(
194+
*, session: SessionDep, org_id: int, project_id: int | None = None
195+
):
196+
try:
197+
creds = remove_creds_for_org(
198+
session=session, org_id=org_id, project_id=project_id
199+
)
200+
if not creds:
201+
raise HTTPException(
202+
status_code=404, detail="Credentials for organization not found"
203+
)
204+
return APIResponse.success_response(
205+
{"message": "Credentials deleted successfully"}
206+
)
207+
except Exception as e:
121208
raise HTTPException(
122-
status_code=404, detail="Credentials for organization not found"
209+
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
123210
)
124-
125-
# No need to manually set deleted_at and is_active if it's done in remove_creds_for_org
126-
# Simply return the success response
127-
return APIResponse.success_response({"message": "Credentials deleted successfully"})

0 commit comments

Comments
 (0)