Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Add project_id in api_key table

Revision ID: 60b6c511a485
Revises: 904ed70e7dab
Create Date: 2025-06-10 13:22:59.947971

"""
from alembic import op
import sqlalchemy as sa
from sqlmodel import text


# revision identifiers, used by Alembic.
revision = "60b6c511a485"
down_revision = "904ed70e7dab"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("apikey", sa.Column("project_id", sa.Integer(), nullable=True))
op.create_foreign_key(
None, "apikey", "project", ["project_id"], ["id"], ondelete="CASCADE"
)

# 2. Bind engine to run raw SQL
conn = op.get_bind()

# 3. Populate project_id based on default logic (e.g., min project id per org)
conn.execute(
text(
"""
UPDATE apikey
SET project_id = (
SELECT id FROM project
WHERE project.organization_id = apikey.organization_id
ORDER BY id ASC
LIMIT 1
)
"""
)
)

Comment on lines +26 to +44
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as this will be one time thing we can run it manually in production server and remove from migration

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to set a NOT NULL constraint on the project_id column.
To do that, I first populated the column with appropriate values to avoid constraint violations.

# 4. Alter column to be NOT NULL now that it’s populated
op.alter_column("apikey", "project_id", nullable=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "apikey", type_="foreignkey")
op.drop_column("apikey", "project_id")
# ### end Alembic commands ###
40 changes: 26 additions & 14 deletions backend/app/api/routes/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from app.crud.api_key import (
create_api_key,
get_api_key,
get_api_keys_by_organization,
delete_api_key,
get_api_key_by_user_org,
get_api_keys_by_project,
get_api_key_by_project_user,
)
from app.crud.organization import validate_organization
from app.crud.project import validate_project
from app.models import APIKeyPublic, User
from app.utils import APIResponse

Expand All @@ -19,7 +19,7 @@
# Create API Key
@router.post("/", response_model=APIResponse[APIKeyPublic])
def create_key(
organization_id: int,
project_id: int,
user_id: uuid.UUID,
session: Session = Depends(get_db),
current_user: User = Depends(get_current_active_superuser),
Expand All @@ -29,18 +29,21 @@
"""
try:
# Validate organization
validate_organization(session, organization_id)
project = validate_project(session, project_id)

existing_api_key = get_api_key_by_user_org(session, organization_id, user_id)
existing_api_key = get_api_key_by_project_user(session, project_id, user_id)
if existing_api_key:
raise HTTPException(
status_code=400,
detail="API Key already exists for this user and organization",
detail="API Key already exists for this user and project.",
)

# Create and return API key
api_key = create_api_key(
session, organization_id=organization_id, user_id=user_id
session,
organization_id=project.organization_id,
user_id=user_id,
project_id=project_id,
)
return APIResponse.success_response(api_key)

Expand All @@ -51,19 +54,28 @@
# List API Keys
@router.get("/", response_model=APIResponse[list[APIKeyPublic]])
def list_keys(
organization_id: int,
project_id: int,
session: Session = Depends(get_db),
current_user: User = Depends(get_current_active_superuser),
):
"""
Retrieve all API keys for the user's organization.
Retrieve all API keys for the given project. Superusers get all keys;
regular users get only their own.
"""
try:
# Validate organization
validate_organization(session, organization_id)
# Validate project
project = validate_project(session=session, project_id=project_id)

if current_user.is_superuser:
# Superuser: fetch all API keys for the project
api_keys = get_api_keys_by_project(session=session, project_id=project_id)
else:
# Regular user: fetch only their own API key
user_api_key = get_api_key_by_project_user(

Check warning on line 74 in backend/app/api/routes/api_keys.py

View check run for this annotation

Codecov / codecov/patch

backend/app/api/routes/api_keys.py#L74

Added line #L74 was not covered by tests
session=session, project_id=project_id, user_id=current_user.id
)
api_keys = [user_api_key] if user_api_key else []

Check warning on line 77 in backend/app/api/routes/api_keys.py

View check run for this annotation

Codecov / codecov/patch

backend/app/api/routes/api_keys.py#L77

Added line #L77 was not covered by tests

# Retrieve API keys
api_keys = get_api_keys_by_organization(session, organization_id)
return APIResponse.success_response(api_keys)

except ValueError as e:
Expand Down
17 changes: 8 additions & 9 deletions backend/app/api/routes/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,17 @@
create_project,
create_user,
create_api_key,
get_api_key_by_user_org,
get_api_key_by_project_user,
)
from app.models import (
OrganizationCreate,
ProjectCreate,
UserCreate,
APIKeyPublic,
Organization,
Project,
User,
APIKey,
)
from app.core.security import get_password_hash
from app.api.deps import (
CurrentUser,
SessionDep,
get_current_active_superuser,
)
Expand Down Expand Up @@ -90,18 +86,21 @@ def onboard_user(request: OnboardingRequest, session: SessionDep):
)
user = create_user(session=session, user_create=user_create)

existing_key = get_api_key_by_user_org(
db=session, organization_id=organization.id, user_id=user.id
existing_key = get_api_key_by_project_user(
session=session, user_id=user.id, project_id=project.id
)

if existing_key:
raise HTTPException(
status_code=400,
detail="API key already exists for this user and organization",
detail="API key already exists for this user and project.",
)

api_key_public = create_api_key(
session=session, organization_id=organization.id, user_id=user.id
session=session,
organization_id=organization.id,
user_id=user.id,
project_id=project.id,
)

user.is_superuser = False
Expand Down
4 changes: 2 additions & 2 deletions backend/app/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
from .api_key import (
create_api_key,
get_api_key,
get_api_key_by_user_org,
get_api_key_by_value,
get_api_keys_by_organization,
get_api_keys_by_project,
get_api_key_by_project_user,
delete_api_key,
)

Expand Down
72 changes: 29 additions & 43 deletions backend/app/crud/api_key.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import uuid
import secrets
from datetime import datetime, timezone
from sqlmodel import Session, select
from app.core.security import (
verify_password,
get_password_hash,
encrypt_api_key,
decrypt_api_key,
)
import base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from app.core import settings
from app.core.util import now

from app.models.api_key import APIKey, APIKeyPublic


Expand All @@ -25,7 +19,7 @@ def generate_api_key() -> tuple[str, str]:


def create_api_key(
session: Session, organization_id: uuid.UUID, user_id: uuid.UUID
session: Session, organization_id: int, user_id: uuid.UUID, project_id: int
) -> APIKeyPublic:
"""
Generates a new API key for an organization and associates it with a user.
Expand All @@ -42,6 +36,7 @@ def create_api_key(
key=encrypted_key, # Store the encrypted raw key
organization_id=organization_id,
user_id=user_id,
project_id=project_id,
)

session.add(api_key)
Expand Down Expand Up @@ -75,32 +70,6 @@ def get_api_key(session: Session, api_key_id: int) -> APIKeyPublic | None:
return None


def get_api_keys_by_organization(
session: Session, organization_id: uuid.UUID
) -> list[APIKeyPublic]:
"""
Retrieves all active API keys associated with an organization.
Returns the API keys in their original format.
"""
api_keys = session.exec(
select(APIKey).where(
APIKey.organization_id == organization_id, APIKey.is_deleted == False
)
).all()

raw_keys = []
for api_key in api_keys:
api_key_dict = api_key.model_dump()

decrypted_key = decrypt_api_key(api_key.key)

api_key_dict["key"] = decrypted_key

raw_keys.append(APIKeyPublic.model_validate(api_key_dict))

return raw_keys


def delete_api_key(session: Session, api_key_id: int) -> None:
"""
Soft deletes (revokes) an API key by marking it as deleted.
Expand Down Expand Up @@ -137,23 +106,40 @@ def get_api_key_by_value(session: Session, api_key_value: str) -> APIKeyPublic |
return None


def get_api_key_by_user_org(
db: Session, organization_id: int, user_id: int
) -> APIKey | None:
"""Get an API key by user and organization ID."""
def get_api_key_by_project_user(
session: Session, project_id: int, user_id: uuid.UUID
) -> APIKeyPublic | None:
"""
Retrieves the single API key associated with a project.
"""
statement = select(APIKey).where(
APIKey.organization_id == organization_id,
APIKey.user_id == user_id,
APIKey.project_id == project_id,
APIKey.is_deleted == False,
)
api_key = db.exec(statement).first()
api_key = session.exec(statement).first()

if api_key:
api_key_dict = api_key.model_dump()
api_key_dict["key"] = decrypt_api_key(api_key.key)
return APIKeyPublic.model_validate(api_key_dict)

decrypted_key = decrypt_api_key(api_key.key)
return None

api_key_dict["key"] = decrypted_key

return APIKey.model_validate(api_key_dict)
return None
def get_api_keys_by_project(session: Session, project_id: int) -> list[APIKeyPublic]:
"""
Retrieves all API keys associated with a project.
"""
statement = select(APIKey).where(
APIKey.project_id == project_id, APIKey.is_deleted == False
)
api_keys = session.exec(statement).all()

result = []
for key in api_keys:
key_dict = key.model_dump()
key_dict["key"] = decrypt_api_key(key.key)
result.append(APIKeyPublic.model_validate(key_dict))

return result
16 changes: 15 additions & 1 deletion backend/app/crud/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import datetime, timezone
from sqlmodel import Session, select

from app.models import Project, ProjectCreate
from app.models import Project, ProjectCreate, Organization
from app.core.util import now


Expand All @@ -24,3 +24,17 @@ def get_project_by_id(*, session: Session, project_id: int) -> Optional[Project]
def get_projects_by_organization(*, session: Session, org_id: int) -> List[Project]:
statement = select(Project).where(Project.organization_id == org_id)
return session.exec(statement).all()


def validate_project(session: Session, project_id: int) -> Project:
"""
Ensures that an project exists and is active.
"""
project = get_project_by_id(session=session, project_id=project_id)
if not project:
raise ValueError("Project not found")

if not project.is_active:
raise ValueError("Project is not active")

return project
4 changes: 4 additions & 0 deletions backend/app/models/api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class APIKeyBase(SQLModel):
organization_id: int = Field(
foreign_key="organization.id", nullable=False, ondelete="CASCADE"
)
project_id: int = Field(
foreign_key="project.id", nullable=False, ondelete="CASCADE"
)
user_id: uuid.UUID = Field(
foreign_key="user.id", nullable=False, ondelete="CASCADE"
)
Expand All @@ -33,4 +36,5 @@ class APIKey(APIKeyBase, table=True):

# Relationships
organization: "Organization" = Relationship(back_populates="api_keys")
project: "Project" = Relationship(back_populates="api_keys")
user: "User" = Relationship(back_populates="api_keys")
3 changes: 3 additions & 0 deletions backend/app/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class Project(ProjectBase, table=True):
creds: list["Credential"] = Relationship(
back_populates="project", sa_relationship_kwargs={"cascade": "all, delete"}
)
api_keys: list["APIKey"] = Relationship(
back_populates="project", sa_relationship_kwargs={"cascade": "all, delete"}
)
organization: Optional["Organization"] = Relationship(back_populates="project")


Expand Down
2 changes: 2 additions & 0 deletions backend/app/seed_data/seed_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@
{
"organization_name": "Project Tech4dev",
"user_email": "[email protected]",
"project_name": "Glific",
"api_key": "ApiKey No3x47A5qoIGhm0kVKjQ77dhCqEdWRIQZlEPzzzh7i8",
"is_deleted": false,
"deleted_at": null
},
{
"organization_name": "Project Tech4dev",
"user_email": "[email protected]",
"project_name": "Dalgo",
"api_key": "ApiKey Px8y47B6roJHin1lWLkR88eiDrFdXSJRZmFQazzai8j9",
"is_deleted": false,
"deleted_at": null
Expand Down
Loading