Skip to content
Merged
5 changes: 4 additions & 1 deletion .claude/commands/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ You are orchestrating a multi-agent code review pipeline for a pull request in t

Execute the eight steps below in order. Each step that says "spawn an agent" must use the Agent tool. Agent prompts are written in English (more reliable for model instructions); the final user-facing comment is in **Turkish** (the project's working language).

> **No nested agents.** Only the orchestrator (this top-level loop) spawns agents — exactly at Steps 1, 2, 3, 4, and 5. Every spawned agent is a **leaf**: it completes its task entirely within its own context and MUST NOT call the `Agent`/`Task` tool or spawn any sub-agent of its own. If work needs to be split, only the orchestrator splits it.

---

### Step 1 — Eligibility check (Sonnet)
Expand Down Expand Up @@ -82,7 +84,7 @@ Pass this object to the five parallel reviewers in Step 4.

### Step 4 — Five parallel Sonnet reviewers

> **Read-only mandate for every spawned agent (Steps 1–5).** Each agent's ONLY output is the JSON described in its prompt. An agent MUST NOT post, edit, or delete anything on the PR: never run `gh pr comment`, `gh issue comment`, `gh pr review`, or any `gh api` call with `-X POST`/`-X PATCH`/`-X PUT`/`-X DELETE`. Only the orchestrator posts, and only once, in Step 8. Any agent that writes to the PR is a pipeline violation and causes duplicate comments.
> **Read-only mandate for every spawned agent (Steps 1–5).** Each agent's ONLY output is the JSON described in its prompt. An agent MUST NOT post, edit, or delete anything on the PR: never run `gh pr comment`, `gh issue comment`, `gh pr review`, or any `gh api` call with `-X POST`/`-X PATCH`/`-X PUT`/`-X DELETE`. Only the orchestrator posts, and only once, in Step 8. Any agent that writes to the PR is a pipeline violation and causes duplicate comments. **Each agent is also a leaf node: it does its own work in its own context and MUST NOT call the `Agent`/`Task` tool or spawn any sub-agent.** A spawned agent that spawns further agents is a pipeline violation.

Spawn the following five agents **in parallel** in a single message, all with `subagent_type: "general-purpose"` and `model: "sonnet"`. Each must return strict JSON of the shape:

Expand Down Expand Up @@ -363,6 +365,7 @@ Use `--body-file` (not `--body`) to preserve markdown formatting and avoid shell

- **Never edit files.** This command only reviews.
- **Only the orchestrator posts, and only in Step 8.** Spawned agents (Steps 1–5) are read-only: they return JSON and never call `gh pr comment`, `gh issue comment`, `gh pr review`, or a writing `gh api` (`-X POST/PATCH/PUT/DELETE`). If a sub-agent reports it already posted a comment, do not post again — verify the PR state and reconcile to exactly one correct comment.
- **No nested agents.** Spawned agents are leaf nodes — they complete their task in their own context and never call the `Agent`/`Task` tool or spawn sub-agents. Only the orchestrator spawns, exactly at Steps 1, 2, 3, 4, and 5.
- **Never post more than one comment per run.** All findings are batched into a single comment.
- **Never post without `REVIEW.md`** — abort instead. The whole pipeline relies on it for false-positive control.
- **Always use the full head SHA** in permalinks, captured at Step 1, even if the PR receives new commits during the run. The review applies to the SHA you actually read.
Expand Down
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ CLOUDINARY_UPLOAD_FOLDER="uploads"
# Max upload size in bytes (default 5 MB). Uploads above this are rejected.
MAX_UPLOAD_SIZE_BYTES=5242880

# Session housekeeping (nightly arq cron). Revoked session rows are kept this
# many days for audit trails before being swept; expired rows go immediately.
# The two cron fields set the daily run time (UTC, 24h).
SESSION_REVOKED_RETENTION_DAYS=30
SESSION_PURGE_CRON_HOUR=4
SESSION_PURGE_CRON_MINUTE=0

# Sentry (only initialized when ENVIRONMENT != "local")
SENTRY_DSN=

Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@ This template integrates the best-in-class Python ecosystem tools to provide a s
- **Security & Auth:** JWT access/refresh tokens accepted via either HttpOnly cookies *or* `Authorization: Bearer`, `bcrypt` password hashing, **Redis-backed token blacklist** (logout invalidation), strict origin-check middleware (returns 404 for foreign origins), and [Slowapi](https://slowapi.readthedocs.io/en/latest/) rate limiting for brute-force protection.
- **Account Lifecycle:** Email verification, password reset, password change, and **soft-delete with grace period** — accounts marked for deletion can be reactivated until the cron worker purges them.
- **File Uploads & Avatars:** [Cloudinary](https://cloudinary.com/)-backed image uploads via `POST /upload` (image-only, 5 MB cap, rate-limited + audited). A generic `File` model lets any feature attach uploads; user avatars enforce **ownership (IDOR) guards** — you may only attach a file you uploaded — and auto-delete the replaced avatar's asset. Admins get full file management (list/filter by uploader, hard-delete with avatar references auto-cleared). See [File Uploads & Avatars](#-file-uploads--avatars) below.
- **Background Jobs:** [arq](https://arq-docs.helpmanual.io/) worker (separate container in compose) runs cron jobs such as `delete_expired_accounts` at the configured time.
- **Background Jobs:** [arq](https://arq-docs.helpmanual.io/) worker (separate container in compose) runs cron jobs such as `delete_expired_accounts` and `purge_stale_sessions` at the configured time.
- **Audit Trail:** `user_activity` table records auth events and CRUD actions with IP / user agent, plus the **HTTP status code** of each event (`200` on success, the raised error code on failure) — filterable in the admin panel. The `audit_unexpected_failure` decorator captures unexpected route failures.
- **Smart Email Validation & Delivery:** Built-in asynchronous email sending with SMTP, domain MX record checking using `dnspython`, and auto-updating disposable email provider filtering via Redis cache.
- **Standardized API Responses:** Global exception handlers standardizing success/error schemas, utilizing a centralized `messages` module (`app/core/messages/`) to prevent hardcoded responses.
- **Role-Based Access Control (RBAC):** Three roles — `user` / `admin` / `superadmin`. Superadmins bypass every check and **create/delete admins** and assign their permissions; plain admins are gated per **`resource:action`** permission (`users:read`, `users:write`, `support:update`, …) stored in `admin_permission` and enforced by a single `require_permission(...)` dependency. The **superadmin tier** (promote an admin to superadmin, demote a superadmin, transfer root via email OTP) is reserved for the **root superadmin** (`is_root_superadmin`); the last active superadmin is always protected. See [Admin & RBAC](#-admin--rbac-roles--permissions).
- **Support / Ticketing System:** Full ticket lifecycle — open, reply with image attachments, status/priority, admin assignment — fanned out over a **Redis-backed WebSocket bus** so the ticket thread, the admin queue, and per-user feeds update live across workers. See [Support System & Realtime](#-support-ticketing-system--realtime).
- **Notification System:** Persistent in-app notifications (`notification` table) layered on the same realtime bus — domain events (a support reply, a ticket status change, an RBAC grant change) call a single `notify()` use case that stores the row **and** pushes it to the recipient's `notifications:{id}` feed, so offline users still find it in their inbox on the next fetch. Notification copy is stored as a stable machine `type` + `data` payload (no human text), so the frontend renders it in any locale. REST covers paginated listing (`unread_only` filter), the unread badge count, and mark-(all-)read. See [Notification System](#-notification-system).
- **Session / Device Management:** Every login opens a `user_session` row whose id rides in both tokens as the JWT `sid` claim, so a user can list their active devices and revoke any one (or all but the current). Refresh **rotates** the session's `jti` and detects **replay** — a stale refresh token revokes the whole session as compromised; logout and password change revoke sessions too. Revocation kills a session's still-valid access tokens instantly via a Redis `sid` flag, and broadcasts `sessions_revoked` so open tabs drop to login live. Admins can list/terminate a user's sessions, gated by the `users:sessions` permission. A nightly arq job purges stale rows. See [Session Management](#-session--device-management).
- **First Superadmin Seed:** On startup a **root superadmin** (`is_root_superadmin`) is created from `FIRST_SUPERUSER` / `FIRST_SUPERUSER_PASSWORD` if none exists (a matching existing account is promoted instead; older deployments get their oldest superadmin flagged as root) — guaranteeing a root account.
- **Tooling:** [uv](https://docs.astral.sh/uv/) for blazing-fast package management, and [Ruff](https://docs.astral.sh/ruff/) for linting and formatting.
- **Testing:** Comprehensive async testing setup with `pytest` and `pytest-asyncio`, in-memory SQLite via `aiosqlite`, `fakeredis`, and autouse SMTP/MX patches — tests never hit Postgres or the network.
Expand Down Expand Up @@ -380,7 +381,7 @@ A complete support desk built on a generic realtime bus that any feature can reu
| `WS /admin/support/ws` · `/support/ws` | admin / owner | Multiplexed ticket + queue feed. |
| `WS /users/me/events` | any user | Per-user account feed (e.g. live `permissions_updated`). |

**Realtime bus (`app/core/realtime.py`).** A thin in-process `ConnectionManager` sits behind a Redis pub/sub bridge: services `publish()` to a topic — `ticket:{id}`, `admin`, `user:{id}`, `account:{id}`, or `notifications:{id}` — and a per-worker listener fans the event to the matching local sockets, so an event raised in one worker reaches clients connected to any worker. Publishing is best-effort (`publish_safe`), so a realtime hiccup never breaks the originating request.
**Realtime bus (`app/core/realtime.py`).** A thin in-process `ConnectionManager` sits behind a Redis pub/sub bridge: services `publish()` to a topic — `ticket:{id}`, `admin`, `user:{id}`, `account:{id}`, or `notifications:{id}` — and a per-worker listener fans the event to the matching local sockets, so an event raised in one worker reaches clients connected to any worker. The `account:{id}` topic carries both `permissions_updated` (RBAC grant change) and `sessions_revoked` (a device was logged out). Publishing is best-effort (`publish_safe`), so a realtime hiccup never breaks the originating request.

---

Expand All @@ -402,6 +403,26 @@ Persistent, per-user in-app notifications built on the same realtime bus as supp

---

## 🔒 Session / Device Management

Per-device login sessions backed by the `user_session` table, so a user can see where they're signed in and revoke access remotely. Each login mints an access + refresh pair carrying the session id as the JWT `sid` claim; the row records the device user-agent (parsed into browser/OS at the schema layer — the raw IP stays server-side and is never returned).

| Method & path | Auth | Purpose |
| --- | --- | --- |
| `GET /users/me/sessions` | self | List own active sessions (paginated), current device flagged. |
| `DELETE /users/me/sessions/{id}` | owner | Revoke one of own sessions (foreign / unknown id → uniform 404). |
| `DELETE /users/me/sessions` | self | Revoke every session **except** the current device. |
| `GET /admin/users/{id}/sessions` | `users:sessions` | List a user's active sessions (admin view). |
| `DELETE /admin/users/{id}/sessions` | `users:sessions` | Terminate all of a user's sessions (remote logout). |

**Rotation & replay (`app/services/auth_service.py`).** Refresh validates the `sid`, rotates the session's stored `refresh_jti`, and pushes the expiry forward. A presented refresh token whose `jti` no longer matches the row is a **replay** — the whole session is revoked as compromised and audited, so a leaked token is single-use even if the Redis blacklist ever loses state. Legacy tokens minted before the feature (no `sid`) are rejected on refresh; the holder simply logs in again.

**Instant revocation.** Revoking a session blacklists its tokens **and** writes a `revoked:session:{sid}` Redis flag (TTL = access-token lifetime), which `get_current_user` checks on every request — so a still-valid access token dies the moment its session is killed, without a per-request DB hit. The same paths broadcast `sessions_revoked` on the `account:{id}` topic so a kicked device's open tab drops to login live. Logout, password change (revokes **all** of the user's sessions), and admin termination all flow through here.

**Housekeeping.** A nightly arq cron (`app/worker/jobs/purge_stale_sessions.py`) deletes expired rows immediately and revoked rows after a retention window (`SESSION_REVOKED_RETENTION_DAYS`, default 30) in one set-based `DELETE`.

---

## 📂 Project Structure

```bash
Expand All @@ -413,7 +434,7 @@ Persistent, per-user in-app notifications built on the same realtime bus as supp
│ │ └── admin/ # Admin surface — RBAC-gated via require_permission;
│ │ # admins/ (admin management) is superadmin-only
│ ├── core/ # config, db, security, redis, rate_limit, email, storage, realtime, bootstrap, messages/
│ ├── models/ # Domain Layer: SQLAlchemy ORM (User, UserActivity, File, SupportTicket, Notification, AdminPermission, …)
│ ├── models/ # Domain Layer: SQLAlchemy ORM (User, UserSession, UserActivity, File, SupportTicket, Notification, AdminPermission, …)
│ ├── repositories/ # Data Layer: async DB queries (no business rules)
│ ├── schemas/ # Pydantic v2 DTOs (Create / Update / Response per domain)
│ ├── services/ # Business Logic Layer: pure async functions, take AsyncSession
Expand Down
56 changes: 56 additions & 0 deletions app/alembic/versions/19b42a1d4b20_add_user_session_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""add user_session table

Revision ID: 19b42a1d4b20
Revises: 5cd8e80e6bce
Create Date: 2026-06-13 00:23:35.196935

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "19b42a1d4b20"
down_revision: str | Sequence[str] | None = "5cd8e80e6bce"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user_session",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("refresh_jti", sa.String(length=36), nullable=False),
sa.Column("user_agent", sa.String(length=512), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_user_session_expires_at", "user_session", ["expires_at"], unique=False
)
op.create_index(
"ix_user_session_user_last_used",
"user_session",
["user_id", "last_used_at"],
unique=False,
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_user_session_user_last_used", table_name="user_session")
op.drop_index("ix_user_session_expires_at", table_name="user_session")
op.drop_table("user_session")
# ### end Alembic commands ###
54 changes: 47 additions & 7 deletions app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
from app.core.config import settings
from app.core.db import AsyncSessionLocal
from app.core.messages.error_message import ErrorMessages
from app.core.security import verify_token
from app.core.security import decode_token_payload
from app.models.user import User
from app.repositories.admin.permission import has_permission
from app.repositories.token_blacklist import is_token_blacklisted
from app.repositories.user import get_user_by_id
from app.repositories.user_session import is_session_revoked
from app.schemas.admin_permission import Permission
from app.schemas.token import TokenPayload
from app.schemas.user import SystemRole
Expand Down Expand Up @@ -57,15 +58,27 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)

token_subject = verify_token(token)
if token_subject is None:
claims = decode_token_payload(token)
if claims is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ErrorMessages.INVALID_TOKEN,
headers={"WWW-Authenticate": "Bearer"},
)

token_data = TokenPayload(sub=token_subject)
# Session guard: a token whose session was revoked (logout elsewhere,
# admin kill, password change) dies here even though its signature and
# expiry are still valid. Tokens without a sid (minted before the
# session feature) pass — they expire within minutes on their own.
sid = claims.get("sid")
if sid and await is_session_revoked(sid):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ErrorMessages.INVALID_TOKEN,
headers={"WWW-Authenticate": "Bearer"},
)

token_data = TokenPayload(sub=claims["sub"], sid=sid, jti=claims.get("jti"))
except (ValidationError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand Down Expand Up @@ -132,10 +145,13 @@ async def get_ws_user(websocket: WebSocket, db: AsyncSession) -> User | None:
try:
if await is_token_blacklisted(token):
return None
token_subject = verify_token(token)
if token_subject is None:
claims = decode_token_payload(token)
if claims is None:
return None
sid = claims.get("sid")
if sid and await is_session_revoked(sid):
return None
token_data = TokenPayload(sub=token_subject)
token_data = TokenPayload(sub=claims["sub"], sid=sid, jti=claims.get("jti"))
except (ValidationError, ValueError):
return None

Expand All @@ -145,6 +161,29 @@ async def get_ws_user(websocket: WebSocket, db: AsyncSession) -> User | None:
return user


async def get_current_session_id(
request: Request,
bearer_token: Annotated[str | None, Depends(reusable_oauth2)] = None,
) -> uuid.UUID | None:
"""Resolve the ``sid`` claim of the caller's access token.

Pure claim extraction — authentication itself is the job of
``get_current_user``, which the route must also depend on. Returns ``None``
for legacy tokens minted before the session feature.
"""
token = request.cookies.get("access_token") or bearer_token
if not token:
return None
claims = decode_token_payload(token)
sid = claims.get("sid") if claims else None
if not sid:
return None
try:
return uuid.UUID(sid)
except ValueError:
return None


# Type aliases for dependency injection
SessionDep = Annotated[AsyncSession, Depends(get_db)]
# reusable_oauth2 uses auto_error=False, so the dependency may resolve to None.
Expand All @@ -153,6 +192,7 @@ async def get_ws_user(websocket: WebSocket, db: AsyncSession) -> User | None:
CurrentActiveUser = Annotated[User, Depends(get_current_active_user)]
CurrentAdminUser = Annotated[User, Depends(get_current_admin_user)]
CurrentSuperAdmin = Annotated[User, Depends(get_current_superadmin)]
CurrentSessionId = Annotated[uuid.UUID | None, Depends(get_current_session_id)]


async def user_has_permission(
Expand Down
Loading
Loading