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
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A self-hosted logging storage and search application with a simple API for log i
- **Simple Ingestion API** - Send logs with 2 lines of code from any language
- **Full-text Search** - Search log messages with PostgreSQL full-text search
- **JSON Metadata** - Attach structured data to logs and filter by any field
- **Multi-team** - Isolate logs by team with separate API keys
- **Multi-team** - Isolate logs by team with multiple API keys per team
- **Retention Policies** - Auto-delete old logs per team
- **Modern Stack** - FastAPI + Vue 3 + Vuetify + PostgreSQL
- **Auto HTTPS** - Caddy handles SSL certificates automatically
Expand Down Expand Up @@ -52,7 +52,7 @@ ADMIN_PASSWORD=changeme

## Sending Logs

Get your API key from the admin panel (Teams → Create Team), then:
Get your API key from the admin panel (Teams → Create Team or Manage Keys), then:

### curl

Expand Down Expand Up @@ -121,6 +121,17 @@ curl -X POST http://localhost/api/v1/ingest/batch \
| `source` | string | No | Service/app name |
| `timestamp` | string | No | ISO 8601 timestamp (default: server time) |

## API Key Management

Each team can have multiple API keys. Manage them from the admin panel:

- **Teams → Key icon** to open the key management dialog
- **Generate** a new key (auto-generated, shown once)
- **Provide manually** a custom key string
- **Revoke** individual keys without affecting others

When a team is created, a default key is generated automatically.

## Searching Logs

In the UI, you can search by:
Expand Down
126 changes: 97 additions & 29 deletions backend/app/api/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.models import User, Team, TeamMembership, TeamRole
from app.models import User, Team, TeamMembership, TeamRole, ApiKey
from app.schemas import (
UserCreate, UserUpdate, UserResponse,
TeamCreate, TeamUpdate, TeamResponse, TeamWithKey,
TeamCreate, TeamUpdate, TeamResponse, TeamCreateResponse,
ApiKeyCreate, ApiKeyResponse, ApiKeyWithSecret,
MembershipCreate, MembershipResponse,
)
from app.api.deps import AdminUser
Expand Down Expand Up @@ -94,35 +95,62 @@ async def delete_user(admin: AdminUser, user_id: UUID):

# ============== Teams ==============

async def _team_response(team: Team) -> TeamResponse:
"""Build a TeamResponse with prefetched api_keys."""
keys = await ApiKey.filter(team=team).all()
return TeamResponse(
id=team.id,
name=team.name,
api_keys=[ApiKeyResponse.model_validate(k, from_attributes=True) for k in keys],
retention_days=team.retention_days,
created_at=team.created_at,
updated_at=team.updated_at,
)


@router.get("/teams", response_model=list[TeamResponse])
async def list_teams(admin: AdminUser):
"""List all teams."""
teams = await Team.all()
return [TeamResponse.model_validate(t, from_attributes=True) for t in teams]
teams = await Team.all().prefetch_related("api_keys")
return [
TeamResponse(
id=t.id,
name=t.name,
api_keys=[ApiKeyResponse.model_validate(k, from_attributes=True) for k in t.api_keys],
retention_days=t.retention_days,
created_at=t.created_at,
updated_at=t.updated_at,
)
for t in teams
]


@router.post("/teams", response_model=TeamWithKey, status_code=status.HTTP_201_CREATED)
@router.post("/teams", response_model=TeamCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_team(admin: AdminUser, data: TeamCreate):
"""Create a new team. Returns the API key (shown only once)."""
"""Create a new team. Returns the first API key (shown only once)."""
existing = await Team.filter(name=data.name).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Team name already exists")

api_key, key_hash, prefix = Team.generate_api_key()

team = await Team.create(
name=data.name,
api_key_hash=key_hash,
api_key_prefix=prefix,
retention_days=data.retention_days,
)

await create_team_partition(team.id)

return TeamWithKey(
api_key, key_hash, prefix = ApiKey.generate_api_key()
key_obj = await ApiKey.create(
team=team,
label="default",
api_key_hash=key_hash,
api_key_prefix=prefix,
)

return TeamCreateResponse(
id=team.id,
name=team.name,
api_key_prefix=team.api_key_prefix,
api_keys=[ApiKeyResponse.model_validate(key_obj, from_attributes=True)],
retention_days=team.retention_days,
created_at=team.created_at,
updated_at=team.updated_at,
Expand All @@ -136,7 +164,7 @@ async def get_team(admin: AdminUser, team_id: UUID):
team = await Team.filter(id=team_id).first()
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")
return TeamResponse.model_validate(team, from_attributes=True)
return await _team_response(team)


@router.put("/teams/{team_id}", response_model=TeamResponse)
Expand All @@ -156,7 +184,7 @@ async def update_team(admin: AdminUser, team_id: UUID, data: TeamUpdate):
team.retention_days = data.retention_days

await team.save()
return TeamResponse.model_validate(team, from_attributes=True)
return await _team_response(team)


@router.delete("/teams/{team_id}")
Expand All @@ -171,28 +199,68 @@ async def delete_team(admin: AdminUser, team_id: UUID):
return {"message": "Team deleted"}


@router.post("/teams/{team_id}/regenerate-key", response_model=TeamWithKey)
async def regenerate_api_key(admin: AdminUser, team_id: UUID):
"""Regenerate a team's API key. Returns the new key (shown only once)."""
# ============== API Keys ==============

@router.get("/teams/{team_id}/api-keys", response_model=list[ApiKeyResponse])
async def list_api_keys(admin: AdminUser, team_id: UUID):
"""List all API keys for a team."""
team = await Team.filter(id=team_id).first()
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")

api_key, key_hash, prefix = Team.generate_api_key()
team.api_key_hash = key_hash
team.api_key_prefix = prefix
await team.save()
keys = await ApiKey.filter(team=team).all()
return [ApiKeyResponse.model_validate(k, from_attributes=True) for k in keys]

return TeamWithKey(
id=team.id,
name=team.name,
api_key_prefix=team.api_key_prefix,
retention_days=team.retention_days,
created_at=team.created_at,
updated_at=team.updated_at,
api_key=api_key,

@router.post("/teams/{team_id}/api-keys", response_model=ApiKeyWithSecret, status_code=status.HTTP_201_CREATED)
async def create_api_key(admin: AdminUser, team_id: UUID, data: ApiKeyCreate):
"""Create a new API key for a team. If api_key is provided, use it; otherwise auto-generate."""
team = await Team.filter(id=team_id).first()
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")

if data.api_key:
# Manually provided key
full_key = data.api_key
key_hash = ApiKey.hash_api_key(full_key)
# Derive prefix from the key (first 10 chars or less)
prefix = full_key[:10] if len(full_key) > 10 else full_key

# Check for duplicate hash
existing = await ApiKey.filter(api_key_hash=key_hash).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This API key already exists")
else:
# Auto-generate
full_key, key_hash, prefix = ApiKey.generate_api_key()

key_obj = await ApiKey.create(
team=team,
label=data.label,
api_key_hash=key_hash,
api_key_prefix=prefix,
)

return ApiKeyWithSecret(
id=key_obj.id,
team_id=team_id,
label=key_obj.label,
api_key_prefix=key_obj.api_key_prefix,
created_at=key_obj.created_at,
api_key=full_key,
)
Comment on lines +215 to +251
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Manual key creation could hit an unhandled IntegrityError on concurrent duplicate.

Lines 230-232 pre-check for a duplicate hash, but there's a TOCTOU window — a concurrent request with the same key could pass the check and then fail on the UNIQUE constraint at insert time, producing a raw 500. Consider catching IntegrityError around the create call.

Wrap create in IntegrityError handler
+    from tortoise.exceptions import IntegrityError
+
+    try:
         key_obj = await ApiKey.create(
             team=team,
             label=data.label,
             api_key_hash=key_hash,
             api_key_prefix=prefix,
         )
+    except IntegrityError:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="This API key already exists",
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@router.post("/teams/{team_id}/api-keys", response_model=ApiKeyWithSecret, status_code=status.HTTP_201_CREATED)
async def create_api_key(admin: AdminUser, team_id: UUID, data: ApiKeyCreate):
"""Create a new API key for a team. If api_key is provided, use it; otherwise auto-generate."""
team = await Team.filter(id=team_id).first()
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")
if data.api_key:
# Manually provided key
full_key = data.api_key
key_hash = ApiKey.hash_api_key(full_key)
# Derive prefix from the key (first 10 chars or less)
prefix = full_key[:10] if len(full_key) > 10 else full_key
# Check for duplicate hash
existing = await ApiKey.filter(api_key_hash=key_hash).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This API key already exists")
else:
# Auto-generate
full_key, key_hash, prefix = ApiKey.generate_api_key()
key_obj = await ApiKey.create(
team=team,
label=data.label,
api_key_hash=key_hash,
api_key_prefix=prefix,
)
return ApiKeyWithSecret(
id=key_obj.id,
team_id=team_id,
label=key_obj.label,
api_key_prefix=key_obj.api_key_prefix,
created_at=key_obj.created_at,
api_key=full_key,
)
`@router.post`("/teams/{team_id}/api-keys", response_model=ApiKeyWithSecret, status_code=status.HTTP_201_CREATED)
async def create_api_key(admin: AdminUser, team_id: UUID, data: ApiKeyCreate):
"""Create a new API key for a team. If api_key is provided, use it; otherwise auto-generate."""
team = await Team.filter(id=team_id).first()
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")
if data.api_key:
# Manually provided key
full_key = data.api_key
key_hash = ApiKey.hash_api_key(full_key)
# Derive prefix from the key (first 10 chars or less)
prefix = full_key[:10] if len(full_key) > 10 else full_key
# Check for duplicate hash
existing = await ApiKey.filter(api_key_hash=key_hash).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This API key already exists")
else:
# Auto-generate
full_key, key_hash, prefix = ApiKey.generate_api_key()
from tortoise.exceptions import IntegrityError
try:
key_obj = await ApiKey.create(
team=team,
label=data.label,
api_key_hash=key_hash,
api_key_prefix=prefix,
)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This API key already exists",
)
return ApiKeyWithSecret(
id=key_obj.id,
team_id=team_id,
label=key_obj.label,
api_key_prefix=key_obj.api_key_prefix,
created_at=key_obj.created_at,
api_key=full_key,
)
🧰 Tools
🪛 Ruff (0.15.1)

[warning] 216-216: Unused function argument: admin

(ARG001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/admin.py` around lines 215 - 251, The create_api_key endpoint
currently does a pre-check for duplicate api_key_hash but can still hit a UNIQUE
constraint race; wrap the ApiKey.create call in a try/except that catches the
database IntegrityError (from your ORM/DB driver), and on IntegrityError
translate it into a HTTPException with status_code 400 and a clear message like
"This API key already exists"; keep the existing pre-check but ensure the except
path handles the concurrent-insert case for ApiKey.create so duplicate hash
insertion doesn't produce a raw 500.



@router.delete("/teams/{team_id}/api-keys/{key_id}")
async def delete_api_key(admin: AdminUser, team_id: UUID, key_id: UUID):
"""Revoke an API key."""
key = await ApiKey.filter(id=key_id, team_id=team_id).first()
if key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")

await key.delete()
return {"message": "API key revoked"}


# ============== Team Members ==============

Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from uuid import UUID
from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.models import User, Team, TeamMembership, TeamRole
from app.models import User, Team, TeamMembership, TeamRole, ApiKey
from app.services.auth import AuthService

security = HTTPBearer()
Expand Down Expand Up @@ -47,7 +47,7 @@ async def get_team_from_api_key(
x_api_key: Annotated[str, Header()]
) -> Team:
"""Validate API key and return the team."""
team = await Team.get_by_api_key(x_api_key)
team = await ApiKey.get_team_by_api_key(x_api_key)
if team is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from app.models.user import User
from app.models.team import Team, TeamMembership, TeamRole
from app.models.api_key import ApiKey
from app.models.log import Log, LogLevel

__all__ = ["User", "Team", "TeamMembership", "TeamRole", "Log", "LogLevel"]
__all__ = ["User", "Team", "TeamMembership", "TeamRole", "ApiKey", "Log", "LogLevel"]
39 changes: 39 additions & 0 deletions backend/app/models/api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from tortoise import fields
from tortoise.models import Model
import secrets
import hashlib


class ApiKey(Model):
id = fields.UUIDField(pk=True)
team = fields.ForeignKeyField("models.Team", related_name="api_keys", on_delete=fields.CASCADE)
label = fields.CharField(max_length=255, default="")
api_key_hash = fields.CharField(max_length=255, unique=True, index=True)
api_key_prefix = fields.CharField(max_length=20)
created_at = fields.DatetimeField(auto_now_add=True)

class Meta:
table = "api_keys"

@staticmethod
def generate_api_key() -> tuple[str, str, str]:
"""Generate a new API key. Returns (full_key, hash, prefix)."""
random_part = secrets.token_urlsafe(32)
prefix = f"sl_{secrets.token_urlsafe(4)}"
full_key = f"{prefix}_{random_part}"
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
return full_key, key_hash, prefix

@staticmethod
def hash_api_key(key: str) -> str:
"""Hash an API key for comparison."""
return hashlib.sha256(key.encode()).hexdigest()

@classmethod
async def get_team_by_api_key(cls, api_key: str):
"""Find a team by API key."""
key_hash = cls.hash_api_key(api_key)
row = await cls.filter(api_key_hash=key_hash).select_related("team").first()
if row is None:
return None
return row.team
25 changes: 1 addition & 24 deletions backend/app/models/team.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from enum import Enum
from tortoise import fields
from tortoise.models import Model
import secrets
import hashlib


class TeamRole(str, Enum):
Expand All @@ -14,39 +12,18 @@ class TeamRole(str, Enum):
class Team(Model):
id = fields.UUIDField(pk=True)
name = fields.CharField(max_length=255, unique=True)
api_key_hash = fields.CharField(max_length=255, unique=True, index=True)
api_key_prefix = fields.CharField(max_length=20) # For identification: sl_xxxx
retention_days = fields.IntField(null=True) # null = keep forever
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)

# Reverse relations
memberships: fields.ReverseRelation["TeamMembership"]
api_keys: fields.ReverseRelation["ApiKey"]
logs: fields.ReverseRelation["Log"]

class Meta:
table = "teams"

@staticmethod
def generate_api_key() -> tuple[str, str, str]:
"""Generate a new API key. Returns (full_key, hash, prefix)."""
random_part = secrets.token_urlsafe(32)
prefix = f"sl_{secrets.token_urlsafe(4)}"
full_key = f"{prefix}_{random_part}"
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
return full_key, key_hash, prefix

@staticmethod
def hash_api_key(key: str) -> str:
"""Hash an API key for comparison."""
return hashlib.sha256(key.encode()).hexdigest()

@classmethod
async def get_by_api_key(cls, api_key: str) -> "Team | None":
"""Find a team by API key."""
key_hash = cls.hash_api_key(api_key)
return await cls.filter(api_key_hash=key_hash).first()


class TeamMembership(Model):
id = fields.UUIDField(pk=True)
Expand Down
10 changes: 8 additions & 2 deletions backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
from app.schemas.auth import Token, TokenPayload, LoginRequest, RefreshRequest
from app.schemas.user import UserCreate, UserUpdate, UserResponse
from app.schemas.team import TeamCreate, TeamUpdate, TeamResponse, TeamWithKey, MembershipCreate, MembershipResponse
from app.schemas.team import (
TeamCreate, TeamUpdate, TeamResponse, TeamCreateResponse,
ApiKeyCreate, ApiKeyResponse, ApiKeyWithSecret,
MembershipCreate, MembershipResponse,
)
from app.schemas.log import LogCreate, LogBatchCreate, LogResponse, LogSearchParams, UserIdBackfillRequest, UserIdBackfillResponse

__all__ = [
"Token", "TokenPayload", "LoginRequest", "RefreshRequest",
"UserCreate", "UserUpdate", "UserResponse",
"TeamCreate", "TeamUpdate", "TeamResponse", "TeamWithKey", "MembershipCreate", "MembershipResponse",
"TeamCreate", "TeamUpdate", "TeamResponse", "TeamCreateResponse",
"ApiKeyCreate", "ApiKeyResponse", "ApiKeyWithSecret",
"MembershipCreate", "MembershipResponse",
"LogCreate", "LogBatchCreate", "LogResponse", "LogSearchParams",
"UserIdBackfillRequest", "UserIdBackfillResponse",
]
Loading