From 6eea6f042c33733a09890e1a255d08eeecf57a02 Mon Sep 17 00:00:00 2001
From: Shashank Chandel <21042676+shashankch@users.noreply.github.com>
Date: Tue, 8 Sep 2026 21:21:11 +0530
Subject: [PATCH] fix(security): enforce multi-tenant task search scoping and
personal task authorization
- Extract currentUserId from @AuthenticationPrincipal Jwt in TaskController.searchTasks
- Scope global task search to user's member teams and personal tasks at database specification level
- Enforce team membership validation on explicit team queries (teamId != null) returning 403 Forbidden for non-members
- Enforce personal task access boundaries across TaskService (read, update, status transition, assignment, deletion)
- Extend parity personal task access validation across TaskCommentService, TaskAttachmentService, and AiAssistantService
- Remove unauthenticated getTaskById(UUID taskId) overload from TaskService
- Add findTeamIdsByUserId port and JPA adapter query in team module
- Expand negative authorization test suites across unit, controller, and integration layers asserting 403 Forbidden
- Add ADR 0009 documenting multi-tenant authorization architecture and create docs/adr/README.md index
- Add STRIDE threat model analysis, IAM endpoint policy matrix, and future phase architecture to docs/architecture.md
- Synchronize README badges, Mermaid diagrams, API specifications, and roadmap with 2026 production standards
---
CHANGELOG.md | 10 +-
README.md | 120 ++++++++-------
docs/ROADMAP.md | 14 +-
docs/adr/0001-modular-monolith.md | 7 +-
docs/adr/0002-postgresql.md | 7 +-
docs/adr/0003-hexagonal-architecture.md | 7 +-
docs/adr/0004-spring-cloud-stream.md | 7 +-
docs/adr/0005-ai-provider-strategy.md | 7 +-
.../0006-opentelemetry-vendor-neutrality.md | 7 +-
...0007-free-tier-pluggable-infrastructure.md | 7 +-
...platform-cloud-paas-deployment-strategy.md | 7 +-
...lti-tenant-task-authorization-hardening.md | 52 +++++++
docs/adr/README.md | 19 +++
docs/api-specification.md | 59 ++++++++
docs/architecture.md | 142 +++++++++++++++++-
.../service/AiAssistantService.java | 29 +++-
.../service/TaskAttachmentService.java | 25 ++-
.../service/TaskCommentService.java | 29 ++--
.../task/adapter/in/TaskController.java | 6 +-
.../task/adapter/out/TaskSpecification.java | 17 ++-
.../application/dto/TaskFilterCriteria.java | 22 ++-
.../task/application/service/TaskService.java | 91 ++++++++---
.../adapter/out/JpaTeamMemberRepository.java | 5 +
.../SpringDataJpaTeamMemberRepository.java | 5 +
.../domain/port/TeamMemberRepository.java | 2 +
.../service/TaskCommentServiceTest.java | 17 +++
.../CollaborationIntegrationTest.java | 49 ++++++
.../task/adapter/in/TaskControllerTest.java | 19 ++-
.../application/service/TaskServiceTest.java | 90 ++++++++++-
.../task/integration/TaskIntegrationTest.java | 70 +++++++++
30 files changed, 822 insertions(+), 126 deletions(-)
create mode 100644 docs/adr/0009-multi-tenant-task-authorization-hardening.md
create mode 100644 docs/adr/README.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b142d9..0197db2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,11 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Security & Multi-Tenant Hardening
+- **Scoped Task Search Authorization**: Enforced authenticated caller identity on `GET /api/v1/tasks`, forbidding cross-team queries and automatically scoping results to the user's personal tasks and member team workspaces.
+- **Personal Task IDOR Defense**: Enforced strict creator/assignee access validation across personal task read, update, status transition, assignment, deletion, comment, attachment, and AI summarization operations.
+- **Removed Unsafe Authorization Overload**: Deleted unauthenticated `getTaskById(UUID taskId)` overload in `TaskService`.
+- **Expanded Authorization Test Suites**: Added negative unit, controller, and end-to-end integration tests verifying `403 Forbidden` across stranger access paths.
+
### Documentation
- Fixed README badge links to point to active CI workflow status and official technology specifications.
- Corrected git clone command and ADR relative markdown paths in README.
- Added ADR 0008 establishing multi-platform deployment strategy across Self-Hosting, PaaS (Railway/Render), Kubernetes, and Major Cloud Providers (AWS/GCP/Azure/OCI).
-- Updated roadmap and architecture specifications with multi-target hosting matrix.
+- Added ADR 0009 documenting multi-tenant task and workspace authorization enforcement architecture.
+- Added comprehensive STRIDE Threat Modeling analysis and Identity & Access Management (IAM) endpoint policy matrix to system architecture specification.
+- Updated roadmap, API specifications, and architecture diagrams with modern 2026 standards.
---
diff --git a/README.md b/README.md
index e5dc44e..70f803a 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,23 @@
-# TaskMaster — Distributed Collaborative Task Platform
-
-[](https://github.com/shashankch/task-master/actions/workflows/ci.yml)
-[-f89820?style=for-the-badge&logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/25/)
-[](https://spring.io/projects/spring-boot)
-[](https://www.postgresql.org/)
-[](https://opentelemetry.io/)
-[](./docs/api/openapi.yaml)
-[](./LICENSE)
+# TaskMaster
+
+### Distributed Collaborative Task Platform
+
+[](https://github.com/shashankch/task-master/actions/workflows/ci.yml)
+[-f89820?style=flat-square&logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/25/)
+[](https://spring.io/projects/spring-boot)
+[](https://www.postgresql.org/)
+[](https://redis.io/)
+[](https://opentelemetry.io/)
+[](./docs/api/openapi.yaml)
+[](./src/test/)
+[](./LICENSE)
**A high-throughput, cloud-native collaborative task tracking and workflow automation platform.**
Engineered with **Java 25 Virtual Threads**, **Spring Boot 4.x**, **Hexagonal Modular Architecture**, **Bidirectional WebSocket/STOMP**, and **Vendor-Agnostic Generative AI**.
-[System Architecture](./docs/architecture.md) • [REST API Specification](./docs/api-specification.md) • [Interactive Swagger UI](http://localhost:8080/swagger-ui.html) • [OpenAPI YAML](./docs/api/openapi.yaml) • [Roadmap](./docs/ROADMAP.md) • [Architecture Decisions (ADRs)](./docs/adr/)
+[System Architecture](./docs/architecture.md) • [REST API Specification](./docs/api-specification.md) • [Interactive Swagger UI](http://localhost:8080/swagger-ui.html) • [OpenAPI YAML](./docs/api/openapi.yaml) • [Roadmap](./docs/ROADMAP.md) • [Architecture Decisions (ADRs)](./docs/adr/README.md)
@@ -23,31 +27,55 @@ Engineered with **Java 25 Virtual Threads**, **Spring Boot 4.x**, **Hexagonal Mo
TaskMaster is an enterprise-grade collaborative task tracking platform designed for high concurrency, low latency, and operational elasticity. Built as a decoupled **Hexagonal Modular Monolith**, each business context functions as an isolated domain that can be scaled monolithically or extracted into independent microservices with zero business logic refactoring.
-```
-┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ CLIENT INGRESS │
-│ REST API (JSON / RFC 7807) │ WebSocket + STOMP (/ws) │ OpenAPI 3.1 │
-└─────────────────────────────────────────┼────────────────────────────────────┼───────────────────┘
- ▼
-┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ TASKMASTER MODULAR CORE (Java 25) │
-│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
-│ │ User & Auth │ │ Team Context │ │ Task Engine │ │ Collaboration│ │ Notification Center │ │
-│ │ (RS256/JWKS) │ │ (RBAC/Invite)│ │ (FTS/State) │ │ (S3/Threads) │ │ (STOMP Broker) │ │
-│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └─────────────────────┘ │
-│ │ │
-│ ┌────────────────────┴────────────────────┐ │
-│ ▼ ▼ │
-│ ┌─────────────────────┐ ┌──────────────────────┐ │
-│ │ Universal AI Engine │ │ OpenTelemetry Engine │ │
-│ │ (OpenAI / Groq/OLL) │ │ (OTLP / Micrometer) │ │
-│ └─────────────────────┘ └──────────────────────┘ │
-└─────────────────────────────────────────┬────────────────────────────────────────────────────────┘
- ▼
-┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
-│ INFRASTRUCTURE DATA TIER │
-│ PostgreSQL 17 (FTS/JSONB) │ Redis 7 (Rate Limit) │ MinIO / AWS S3 │ Jaeger / Prom (OTel)│
-└──────────────────────────────────────────────────────────────────────────────────────────────────┘
+```mermaid
+flowchart TB
+ subgraph CLIENTS["🌐 Ingress & API Consumers"]
+ direction LR
+ SPA["💻 Web UI (Next.js 15 / React 19)"]
+ MOBILE["📱 Mobile & API Clients"]
+ WS_CLIENT["⚡ WebSocket STOMP Client"]
+ end
+
+ subgraph GW["🔐 Security Perimeter & Rate Limiter"]
+ direction LR
+ RATE["Redis Sliding-Window Limiter\n(Caffeine In-Memory Fallback)"]
+ JWT_AUTH["OAuth2 / RS256 JWT Filter\n(RFC 7517 JWKS Discovery)"]
+ IDOR_GATE["Multi-Tenant Authorization Gate\n(Personal Tasks & Team Workspaces)"]
+ RATE --> JWT_AUTH --> IDOR_GATE
+ end
+
+ subgraph CORE["⚙️ TaskMaster Modular Monolith Core (Java 25 LTS · Virtual Threads)"]
+ direction TB
+ subgraph MODULES["Hexagonal Domain Modules"]
+ direction LR
+ M_USER["👤 User & Identity\n(Auth, Roles, Tokens)"]
+ M_TEAM["👥 Team Workspace\n(RBAC, Invites)"]
+ M_TASK["✅ Task Engine\n(FSM, Criteria, Optimistic Lock)"]
+ M_COLLAB["💬 Collaboration\n(Threads, S3 Pre-Signed)"]
+ M_NOTIF["🔔 Notifications\n(STOMP Broker)"]
+ end
+ subgraph CROSS_CUTTING["Cross-Cutting Intelligence & Observability"]
+ direction LR
+ AI_ENG["🤖 Universal AI Engine\n(OpenAI / Groq / Gemini / Ollama)"]
+ OTEL_ENG["📊 OpenTelemetry Engine\n(OTLP Exporter / Micrometer)"]
+ end
+ MODULES --> CROSS_CUTTING
+ end
+
+ subgraph STORAGE["🗄️ Resilient Infrastructure Tier"]
+ direction LR
+ PG[("🐘 PostgreSQL 17\n(ACID · tsvector GIN · JSONB)")]
+ REDIS[("🔴 Redis 7\n(Rate Limiter · Session Cache)")]
+ S3[("🪣 AWS S3 / MinIO\n(Binary File Attachments)")]
+ JAEGER[("🔭 Jaeger Collector\n(OpenTelemetry Tracing)")]
+ end
+
+ CLIENTS --> GW
+ GW --> CORE
+ CORE --> PG
+ CORE --> REDIS
+ CORE --> S3
+ CORE --> JAEGER
```
---
@@ -58,6 +86,7 @@ TaskMaster is an enterprise-grade collaborative task tracking platform designed
- **Asymmetric RS256 Tokens**: Cryptographic access token signing with persistent PEM keys and public RFC 7517 JWKS discovery (`/.well-known/jwks.json`).
- **Refresh Token Family Rotation**: Single-use refresh tokens with automatic family revocation upon replay attack detection.
- **Strict Authorization & IDOR Gates**: Team-level boundary enforcement preventing unauthorized cross-tenant mutations.
+- **Multi-Tenant Search Scoping**: `TaskSpecification` dynamically scopes queries to joined workspaces and personal tasks (`teamId IN (:allowedTeamIds) OR (teamId IS NULL AND (creator = :userId OR assignee = :userId))`).
- **Distributed Rate Limiting**: Sliding-window rate limiter using Redis ZSET with bounded Caffeine LRU cache fallback.
### 📋 Task Lifecycle & Execution Engine
@@ -177,7 +206,7 @@ TaskMaster provides interactive documentation and static OpenAPI 3.1 schemas:
TaskMaster enforces strict automated verification across unit, integration, and architecture layers:
```bash
-# Run all automated unit and integration tests (102 tests)
+# Run all automated unit and integration tests (110 tests)
./gradlew test
# Run Checkstyle static code analysis and architecture rules
@@ -196,21 +225,7 @@ Hexagonal boundary purity and modular isolation are enforced at compile-time via
## 🗺️ Product Roadmap
-Track development progress across engineering phases:
-
-- ✅ **Phase 1: Foundation & Project Setup** (Java 25, Spring Boot 4, Docker Compose, Base Entities, RFC 7807)
-- ✅ **Phase 2: User Authentication & Authorization** (RS256 JWT, JWKS, Refresh Token Rotation, Rate Limiting)
-- ✅ **Phase 3: Task Management** (State Machine, JPA Criteria, GIN Full-Text Search, Optimistic Locking)
-- ✅ **Phase 4: Team Collaboration** (Workspaces, RBAC, Threaded Comments, S3 Pre-Signed Storage)
-- ✅ **Phase 5: Real-time Notifications & AI Integration** (WebSocket STOMP, Pluggable AI Assistant)
-- ✅ **Phase 6: Production Hardening, Security Remediation & Open Standards** (Persistent RSA Keys, OTel Tracing, OpenAPI 3.1 YAML, S3 Client Engine, IDOR Authorization)
-- ⬜ **Phase 7: Advanced Search & Analytics** (Elasticsearch Cluster Sync, Velocity Metrics, Audit Trails)
-- ⬜ **Phase 8: Performance & Reliability** (Distributed Redis Rate Limiting, Resilience4j Circuit Breaking)
-- ⬜ **Phase 9: DevOps, Cloud & Multi-Platform Deployment** (Self-Hosting, PaaS Railway/Render, Distroless Containerization, Kubernetes HPA, AWS/GCP/Azure/OCI)
-- ⬜ **Phase 10: Extended Platform Capabilities** (DAG Task Dependencies, Recurring Automation, Kanban Positional Engine)
-- ⬜ **Phase 11: Modern Collaborative Web Application (Frontend)** (Next.js 15, React 19, Tailwind CSS, Kanban UI, Real-time WebSocket Client)
-
-For in-depth milestone specifications, see [`docs/ROADMAP.md`](./docs/ROADMAP.md).
+For comprehensive milestone breakdowns, delivery statuses, and planned engineering horizons across all phases, see [`docs/ROADMAP.md`](./docs/ROADMAP.md).
---
@@ -219,7 +234,7 @@ For in-depth milestone specifications, see [`docs/ROADMAP.md`](./docs/ROADMAP.md
- 📐 **System Architecture & C4 Diagrams**: [`docs/architecture.md`](./docs/architecture.md)
- 📄 **API Specification & Request Payloads**: [`docs/api-specification.md`](./docs/api-specification.md)
- 📋 **Static OpenAPI 3.1 YAML Schema**: [`docs/api/openapi.yaml`](./docs/api/openapi.yaml)
-- 🏛️ **Architecture Decision Records (ADRs)**: [`docs/adr/`](./docs/adr/)
+- 🏛️ **Architecture Decision Records (ADRs)**: [`docs/adr/README.md`](./docs/adr/README.md)
- [ADR 0001: Modular Monolith Architecture](./docs/adr/0001-modular-monolith.md)
- [ADR 0002: PostgreSQL & Flyway Strategy](./docs/adr/0002-postgresql.md)
- [ADR 0003: Hexagonal Architecture Boundaries](./docs/adr/0003-hexagonal-architecture.md)
@@ -228,6 +243,7 @@ For in-depth milestone specifications, see [`docs/ROADMAP.md`](./docs/ROADMAP.md
- [ADR 0006: OpenTelemetry (OTel) Standard for Observability](./docs/adr/0006-opentelemetry-vendor-neutrality.md)
- [ADR 0007: Pluggable Free-Tier & Open-Source First Strategy](./docs/adr/0007-free-tier-pluggable-infrastructure.md)
- [ADR 0008: Multi-Platform Cloud, PaaS & Container Deployment Strategy](./docs/adr/0008-multi-platform-cloud-paas-deployment-strategy.md)
+ - [ADR 0009: Multi-Tenant Task Authorization Hardening](./docs/adr/0009-multi-tenant-task-authorization-hardening.md)
- 🤝 **Contributing Guidelines**: [`CONTRIBUTING.md`](./CONTRIBUTING.md)
---
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index fa2a867..d8be078 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,5 +1,13 @@
# TaskMaster Product Roadmap
+> **Document Metadata**
+> - **Title**: TaskMaster Product & Engineering Delivery Roadmap
+> - **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+> - **Status**: Approved / Living Engineering Roadmap
+> - **Last Updated**: 2026-09-08
+> - **Authoritative Location**: [docs/ROADMAP.md](ROADMAP.md)
+> - **Related Documents**: [System Architecture](architecture.md) | [REST API Specification](api-specification.md) | [Architecture Decisions (ADRs)](adr/README.md)
+
A trackable overview of delivery milestones and planned capabilities for the TaskMaster collaborative platform.
---
@@ -46,12 +54,16 @@ A trackable overview of delivery milestones and planned capabilities for the Tas
- Persistent PEM-encoded RSA key pair for zero-session-loss restarts and multi-replica consistency
- Environment-driven CORS origin allowlist eliminating wildcard credential exposure
- Real AWS SDK v2 S3/MinIO durable file storage adapter with pre-signed URL offloading
- - Team-scoped authorization checks preventing IDOR vulnerabilities across task operations
+ - Scoped task search authorization (`GET /api/v1/tasks`) isolating queries to member teams and personal tasks at database level
+ - Personal task ownership boundaries (`teamId == null`) restricting read, update, status change, assign, and delete to creator/assignee
+ - Subsystem authorization parity across comments (`TaskCommentService`), attachments (`TaskAttachmentService`), and AI assistant (`AiAssistantService`)
+ - Permanent removal of unauthenticated `getTaskById(UUID)` bypass overload
- Vendor-neutral OpenTelemetry (OTel) distributed tracing via standard OTLP protocol
- Validated OpenAPI 3.1 YAML specification (`docs/api/openapi.yaml`) with CI export verification
- Bounded Caffeine LRU cache fallback for sliding-window rate limiter
- Java 25 compiler bytecode release alignment and expanded ArchUnit regression rule suite
- Hibernate `@SQLRestriction` for automatic soft-delete query protection
+ - Negative authorization test suite expansion across unit, controller, and integration layers asserting RFC 7807 `403 Forbidden` (110 passing automated tests)
---
diff --git a/docs/adr/0001-modular-monolith.md b/docs/adr/0001-modular-monolith.md
index 480fa7f..8639f6f 100644
--- a/docs/adr/0001-modular-monolith.md
+++ b/docs/adr/0001-modular-monolith.md
@@ -1,7 +1,10 @@
# ADR 0001: Adopt Modular Monolith Architecture
-## Status
-Accepted
+- **Status**: Accepted
+- **Approval Date**: 2026-08-20
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
## Context
When designing the backend for TaskMaster, we considered whether to adopt a microservices architecture or a monolithic architecture.
diff --git a/docs/adr/0002-postgresql.md b/docs/adr/0002-postgresql.md
index 530d1c6..f06be2c 100644
--- a/docs/adr/0002-postgresql.md
+++ b/docs/adr/0002-postgresql.md
@@ -1,7 +1,10 @@
# ADR 0002: Use PostgreSQL 17 as Primary Datastore
-## Status
-Accepted
+- **Status**: Accepted
+- **Approval Date**: 2026-08-20
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
## Context
TaskMaster requires robust data storage supporting relational entities (users, teams, memberships, tasks, comments, attachments) with strict transactional consistency guarantees. We evaluated relational (PostgreSQL, MySQL) and document-oriented (MongoDB) databases.
diff --git a/docs/adr/0003-hexagonal-architecture.md b/docs/adr/0003-hexagonal-architecture.md
index f65902a..19deec8 100644
--- a/docs/adr/0003-hexagonal-architecture.md
+++ b/docs/adr/0003-hexagonal-architecture.md
@@ -1,7 +1,10 @@
# ADR 0003: Hexagonal (Ports and Adapters) Architecture
-## Status
-Accepted
+- **Status**: Accepted
+- **Approval Date**: 2026-08-20
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
## Context
In traditional layered architectures, business domain models frequently become coupled to ORM entities, web frameworks, or third-party SDKs. This makes unit testing tedious and complicates switching infrastructure providers (e.g., changing storage providers from MinIO to AWS S3).
diff --git a/docs/adr/0004-spring-cloud-stream.md b/docs/adr/0004-spring-cloud-stream.md
index c864a38..2a22577 100644
--- a/docs/adr/0004-spring-cloud-stream.md
+++ b/docs/adr/0004-spring-cloud-stream.md
@@ -1,7 +1,10 @@
# ADR 0004: Event-Driven Messaging with Spring Cloud Stream & RabbitMQ
-## Status
-Accepted
+- **Status**: Accepted
+- **Approval Date**: 2026-08-20
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
## Context
Asynchronous domain events (e.g. task assignments, mentions, status updates) require reliable decoupling between event publication and consumption (such as generating notification records and dispatching real-time WebSocket pushes).
diff --git a/docs/adr/0005-ai-provider-strategy.md b/docs/adr/0005-ai-provider-strategy.md
index b31b190..ef021c2 100644
--- a/docs/adr/0005-ai-provider-strategy.md
+++ b/docs/adr/0005-ai-provider-strategy.md
@@ -1,7 +1,10 @@
# ADR 0005: Universal OpenAI-Compatible AI Provider & Gateway Strategy
-## Status
-Accepted
+- **Status**: Accepted
+- **Approval Date**: 2026-08-27
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
## Context
Generative AI capabilities (task description synthesis, comment thread summarization, priority recommendation, semantic duplicate detection, and label tagging) must be flexible, vendor-agnostic, and cost-effective across diverse local, cloud, and enterprise environments without locking into proprietary vendor SDKs.
diff --git a/docs/adr/0006-opentelemetry-vendor-neutrality.md b/docs/adr/0006-opentelemetry-vendor-neutrality.md
index 6da9e80..90082da 100644
--- a/docs/adr/0006-opentelemetry-vendor-neutrality.md
+++ b/docs/adr/0006-opentelemetry-vendor-neutrality.md
@@ -1,7 +1,10 @@
# ADR 0006: OpenTelemetry (OTel) Standard for Vendor-Neutral Observability
-## Status
-Accepted
+- **Status**: Accepted
+- **Approval Date**: 2026-08-29
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
## Context
Production-grade distributed systems require comprehensive observability across distributed tracing, application metrics, and structured log aggregation. Proprietary APM SDKs (e.g., Datadog, New Relic, Dynatrace, AWS X-Ray) create strong vendor lock-in, proprietary agent maintenance overhead, and steep licensing lock-in.
diff --git a/docs/adr/0007-free-tier-pluggable-infrastructure.md b/docs/adr/0007-free-tier-pluggable-infrastructure.md
index fb3a23d..4ec9c32 100644
--- a/docs/adr/0007-free-tier-pluggable-infrastructure.md
+++ b/docs/adr/0007-free-tier-pluggable-infrastructure.md
@@ -1,7 +1,10 @@
# ADR 0007: Pluggable Free-Tier & Open-Source First Infrastructure Strategy
-## Status
-Accepted
+- **Status**: Accepted
+- **Approval Date**: 2026-08-29
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
## Context
Deploying, developing, and evaluating modern cloud-native systems requires minimizing operational friction, licensing barriers, and proprietary infrastructure dependencies while preserving seamless forward compatibility with managed enterprise cloud platforms.
diff --git a/docs/adr/0008-multi-platform-cloud-paas-deployment-strategy.md b/docs/adr/0008-multi-platform-cloud-paas-deployment-strategy.md
index 1706823..d747d2f 100644
--- a/docs/adr/0008-multi-platform-cloud-paas-deployment-strategy.md
+++ b/docs/adr/0008-multi-platform-cloud-paas-deployment-strategy.md
@@ -1,7 +1,10 @@
# ADR 0008: Multi-Platform Cloud, PaaS & Container Deployment Strategy
-## Status
-Accepted
+- **Status**: Accepted
+- **Approval Date**: 2026-09-03
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
## Context
TaskMaster is designed as a cloud-native collaborative task platform that must run seamlessly across diverse hosting environments—from a developer's local laptop to enterprise Kubernetes clusters and modern Platform-as-a-Service (PaaS) providers—without requiring code modifications or proprietary vendor lock-in.
diff --git a/docs/adr/0009-multi-tenant-task-authorization-hardening.md b/docs/adr/0009-multi-tenant-task-authorization-hardening.md
new file mode 100644
index 0000000..8456c17
--- /dev/null
+++ b/docs/adr/0009-multi-tenant-task-authorization-hardening.md
@@ -0,0 +1,52 @@
+# ADR 0009: Multi-Tenant Task and Workspace Authorization Enforcement
+
+- **Status**: Accepted
+- **Approval Date**: 2026-09-08
+- **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+
+---
+
+## Context
+
+TaskMaster supports collaborative team workspaces as well as private personal tasks. Tasks without an associated team (`teamId == null`) represent personal workflows, while tasks with a `teamId` belong to shared workspaces governed by team roles (`OWNER`, `ADMIN`, `MEMBER`).
+
+A security evaluation identified two critical authorization vulnerabilities:
+1. **Unscoped Task Queries**: The task filtering endpoint (`GET /api/v1/tasks`) did not bind the authenticated user identity or validate workspace membership, allowing users to discover tasks from other teams or view other users' private personal tasks.
+2. **Personal Task IDOR Vulnerability**: Task access checks used a short-circuit guard `if (task.getTeamId() != null && !existsByTeamIdAndUserId(...))`. When `teamId == null`, this check was skipped, permitting any authenticated user knowing a task UUID to read, modify, assign, or delete personal tasks.
+3. **Unauthenticated Service Overload**: An overloaded service method `getTaskById(UUID taskId)` bypassed security context validation.
+
+## Decision
+
+We mandate comprehensive, zero-trust authorization enforcement across personal tasks, team workspaces, and task search queries:
+
+1. **Ingress Identity Binding**:
+ Extract `currentUserId` directly from `@AuthenticationPrincipal Jwt` in `TaskController.searchTasks(...)` and all mutating endpoints.
+2. **Explicit Team Search Verification**:
+ If a search query specifies `teamId != null`, `TaskService` must verify that `currentUserId` is an active member of that team; otherwise, return RFC 7807 `403 Forbidden`.
+3. **Dynamic Database Scoping**:
+ If `teamId` is omitted (`null`), query all team IDs the caller belongs to (`allowedTeamIds`) and pass them to `TaskSpecification`. Dynamically construct a database predicate:
+ ```sql
+ WHERE (team_id IN (:allowedTeamIds))
+ OR (team_id IS NULL AND (created_by = :currentUserId OR assignee_id = :currentUserId))
+ ```
+4. **Explicit Personal Task Authorization Boundary**:
+ For tasks where `teamId == null`:
+ - Read, update, and status transitions require `currentUserId` to be either the task creator or assignee.
+ - Assignment and deletion require `currentUserId` to be the task creator.
+ - Strangers receive RFC 7807 `403 Forbidden` (`You do not have permission to access this task`).
+5. **Subsystem Authorization Parity**:
+ Enforce identical personal task and team workspace checks across comments (`TaskCommentService`), file attachments (`TaskAttachmentService`), and AI assistant operations (`AiAssistantService`).
+6. **Elimination of Unauthenticated Overloads**:
+ Permanently remove `getTaskById(UUID taskId)` without user context from `TaskService`.
+7. **Negative Authorization Test Suites**:
+ Maintain comprehensive negative unit, controller, and integration tests verifying HTTP `403 Forbidden` for stranger personal task access and unjoined team queries.
+
+## Consequences
+
+### Positive
+- **Eliminates IDOR & Data Leakage**: Personal tasks and team backlogs are strictly partitioned by tenant and ownership boundaries.
+- **Database-Level Query Scoping**: Performance is preserved by filtering unauthorized rows directly in SQL rather than post-filtering in memory.
+- **Defense in Depth**: Access control is enforced in domain services regardless of the ingress transport (REST, internal events, or future RPC).
+
+### Trade-offs
+- An additional query to `team_members` is executed during global task searches when `teamId` is not specified to resolve `allowedTeamIds`. This query is indexed on `idx_team_members_user_id` and has negligible latency.
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 0000000..6c4a3f3
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,19 @@
+# TaskMaster — Architecture Decision Records (ADRs)
+
+This directory documents all significant architectural and technical decisions made for the TaskMaster platform. Each record follows the standard Michael Nygard ADR format (Context, Decision, Consequences).
+
+---
+
+## Index of Architectural Decisions
+
+| ADR | Title | Approval Date | Status | Summary |
+| :--- | :--- | :--- | :--- | :--- |
+| [**0001**](0001-modular-monolith.md) | Modular Monolith Architecture | 2026-08-20 | Accepted | Modular monolith with clean hexagonal package boundaries per business module over microservices. |
+| [**0002**](0002-postgresql.md) | PostgreSQL & Flyway Strategy | 2026-08-20 | Accepted | PostgreSQL 17 relational datastore with version-controlled Flyway migrations, tsvector FTS, and JSONB. |
+| [**0003**](0003-hexagonal-architecture.md) | Hexagonal Architecture Boundaries | 2026-08-20 | Accepted | Ports & Adapters architecture within each domain module enforced at compile time by ArchUnit. |
+| [**0004**](0004-spring-cloud-stream.md) | Event-Driven Architecture with Spring Cloud Stream | 2026-08-20 | Accepted | Broker-agnostic messaging using Spring Cloud Stream binders (RabbitMQ in dev, swappable to Kafka). |
+| [**0005**](0005-ai-provider-strategy.md) | Universal OpenAI-Compatible AI Strategy | 2026-08-27 | Accepted | Universal `/chat/completions` REST client swappable across Groq, Gemini, Ollama, and AI Gateways. |
+| [**0006**](0006-opentelemetry-vendor-neutrality.md) | OpenTelemetry (OTel) Standard for Observability | 2026-08-29 | Accepted | Vendor-neutral distributed tracing via standard OTLP HTTP exporter and W3C TraceContext propagation. |
+| [**0007**](0007-free-tier-pluggable-infrastructure.md) | Pluggable Free-Tier & Open-Source First Strategy | 2026-08-29 | Accepted | Zero-cost local development defaults (Docker/MinIO/Jaeger) with 1-line environment variable cloud upgrade. |
+| [**0008**](0008-multi-platform-cloud-paas-deployment-strategy.md) | Multi-Platform Cloud, PaaS & Container Deployment Strategy | 2026-09-03 | Accepted | Comprehensive multi-target deployment matrix covering Self-Hosting, PaaS (Railway/Render), K8s, and Cloud. |
+| [**0009**](0009-multi-tenant-task-authorization-hardening.md) | Multi-Tenant Task Authorization Hardening | 2026-09-08 | Accepted | Scoped task search queries, personal task IDOR defense, and negative authorization testing. |
diff --git a/docs/api-specification.md b/docs/api-specification.md
index 9011114..08da4bb 100644
--- a/docs/api-specification.md
+++ b/docs/api-specification.md
@@ -1,5 +1,13 @@
# TaskMaster API Specification
+> **Document Metadata**
+> - **Title**: TaskMaster REST API Interface & Schema Specification
+> - **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+> - **Status**: Approved / Living Specification
+> - **Last Updated**: 2026-09-08
+> - **Authoritative Location**: [docs/api-specification.md](api-specification.md)
+> - **Related Documents**: [System Architecture](architecture.md) | [OpenAPI 3.1 Spec](api/openapi.yaml) | [Phased Roadmap](ROADMAP.md) | [Architecture Decisions (ADRs)](adr/README.md)
+
Comprehensive REST API reference for the TaskMaster collaborative task tracking platform.
- **Base URL**: `http://localhost:8080/api/v1` (Development)
@@ -261,11 +269,17 @@ All non-2xx responses adhere to the **RFC 7807 `ProblemDetail`** standard:
#### 3.2 Get Task by ID
- **Method**: `GET /api/v1/tasks/{id}`
- **Auth**: `Bearer `
+- **Authorization**:
+ - Team tasks (`teamId != null`): User must be an active member of the task's team (`403 Forbidden` otherwise).
+ - Personal tasks (`teamId == null`): User must be the task's creator or assignee (`403 Forbidden` otherwise).
- **Response**: `200 OK` (`TaskResponse`)
#### 3.3 Search & Filter Tasks
- **Method**: `GET /api/v1/tasks`
- **Auth**: `Bearer `
+- **Authorization & Scoping**:
+ - **Explicit Team Query (`teamId != null`)**: Requires user to be an active member of the specified team (`403 Forbidden` otherwise).
+ - **Global Search (`teamId == null`)**: Automatically scoped to tasks in all teams the user belongs to, plus the user's own personal tasks (creator or assignee). Unjoined team tasks and stranger personal tasks are strictly isolated and excluded.
- **Query Filters**:
- `status` (e.g. `OPEN`, `IN_PROGRESS`, `REVIEW`, `COMPLETED`, `ARCHIVED`)
- `priority` (e.g. `LOW`, `MEDIUM`, `HIGH`, `URGENT`)
@@ -702,4 +716,49 @@ All non-2xx responses adhere to the **RFC 7807 `ProblemDetail`** standard:
}
```
+---
+
+## 9. Planned Future Endpoints
+
+### 9.1 Advanced Search & Analytics (Phase 7)
+
+#### Full-Text Typo-Tolerant Search
+- **Method**: `GET /api/v1/search/tasks`
+- **Auth**: `Bearer `
+- **Query Parameters**:
+ - `q` (search query string)
+ - `teamId` (optional UUID)
+ - `fuzzy` (boolean, default `true`)
+ - `page`, `size`
+- **Response**: `200 OK` (`PageResponse>` with highlighting snippets)
+
+#### Team Velocity & Throughput Metrics
+- **Method**: `GET /api/v1/analytics/teams/{teamId}/velocity`
+- **Auth**: `Bearer ` (Team Member only)
+- **Query Parameters**: `period` (`WEEKLY`, `MONTHLY`, `QUARTERLY`)
+- **Response**: `200 OK` (`TeamVelocityResponse` with completed story points and task completion throughput)
+
+#### Task Cycle Time & Lead Time Distribution
+- **Method**: `GET /api/v1/analytics/teams/{teamId}/cycle-time`
+- **Auth**: `Bearer ` (Team Member only)
+- **Response**: `200 OK` (`CycleTimeAnalyticsResponse` with percentile latency metrics: p50, p90, p99)
+
+---
+
+### 9.2 Directed Acyclic Graph (DAG) Task Dependencies (Phase 10)
+
+#### Add Prerequisite Task Dependency
+- **Method**: `POST /api/v1/tasks/{id}/dependencies`
+- **Auth**: `Bearer ` (Team Member or Personal Task Creator)
+- **Request Body**:
+```json
+{
+ "dependsOnTaskId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+}
+```
+- **Response**: `201 Created` (Enforces cycle detection via Kahn's algorithm; returns `400 Bad Request` on circular dependency)
+#### Get Task Dependency Graph
+- **Method**: `GET /api/v1/tasks/{id}/dag`
+- **Auth**: `Bearer `
+- **Response**: `200 OK` (`TaskDagResponse` with upstream blocking tasks and downstream dependants)
diff --git a/docs/architecture.md b/docs/architecture.md
index 0bb6b23..202edc5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,5 +1,13 @@
# TaskMaster — System Architecture & Technical Design Specification
+> **Document Metadata**
+> - **Title**: TaskMaster Core System Architecture & Technical Design Specification
+> - **Author**: TaskMaster Engineering (`shashakchandel@gmail.com`)
+> - **Status**: Approved / Living Design Document
+> - **Last Updated**: 2026-09-08
+> - **Authoritative Location**: [docs/architecture.md](architecture.md)
+> - **Related Documents**: [REST API Specification](api-specification.md) | [OpenAPI 3.1 Spec](api/openapi.yaml) | [Phased Roadmap](ROADMAP.md) | [Architecture Decisions (ADRs)](adr/README.md) | [Contributing Guide](../CONTRIBUTING.md)
+
This document provides the definitive, production-grade technical specification of the TaskMaster platform. It documents the end-to-end architecture, C4 structural diagrams, subsystem sequences, data schemas, security perimeters, resilience mechanisms, and cloud-native deployment topology.
---
@@ -196,7 +204,7 @@ flowchart TD
F2["② CorsFilter\nValidate Origin against allowlist\nPreset CORS response headers"]
F3["③ JWT Authentication Filter\nValidate RS256 signature via RSA public key\nExtract subject + roles → SecurityContext"]
F4["④ Sliding-Window Rate Limiter\nRedis ZSET atomic window check\nkey = IP address or user-id"]
- F5["⑤ Method Security Interceptor\n@PreAuthorize role & ownership evaluation\nSpring Security AOP proxy"]
+ F5["⑤ Multi-Tenant & Domain Authorization Gates\nTeam membership · Personal task boundary\nScoped search query composition"]
OK(["⑥ Controller Handler Execution\nBusiness Logic Invoked"])
E401(["401 Unauthorized\nRFC 7807 ProblemDetail"])
@@ -212,10 +220,78 @@ flowchart TD
F4 -- "Limit Exceeded" --> E429
F4 -- "Within Rate Limit ✓" --> F5
- F5 -- "Insufficient Role/Ownership" --> E403
+ F5 -- "Non-Member / Stranger Access" --> E403
F5 -- "Authorized ✓" --> OK
```
+### 4.1 Ingress Filter & Rate Limiting Chain
+- **Correlation Tracking**: Injected via `CorrelationIdFilter` on inbound requests, mapped to SLF4J MDC, and returned via `X-Correlation-ID` header.
+- **CORS Defense**: Enforced by `CorsConfig` validating incoming origins against configurable `app.cors.allowed-origins`. Wildcard `*` is strictly prohibited in production.
+- **Asymmetric RS256 JWT Verification**: Inbound Bearer tokens are verified against the RSA public key. Public keys are exposed via RFC 7517 JWKS (`/.well-known/jwks.json`).
+- **Sliding-Window Rate Limiting**: Managed via Redis atomic ZSET sliding windows with automatic fallback to bounded in-memory Caffeine caches.
+
+### 4.2 Multi-Tenant Data Isolation & Authorization Architecture
+TaskMaster enforces zero-trust data segregation across multi-user workspaces and private personal workflows:
+
+1. **Team Workspace Boundary**:
+ - Every team-scoped resource (team tasks, comments, attachments, workspaces) requires verified membership in `team_members` (`teamMemberRepository.findRoleByTeamIdAndUserId`).
+ - Non-members attempting read, write, assign, or delete operations receive RFC 7807 `403 Forbidden` (`You do not have permission to access tasks in this team`).
+2. **Personal Task Boundary (`teamId == null`)**:
+ - Tasks created without a team are strictly private to the creator and assignee.
+ - **Read, Update, and Status Transitions**: Permitted only if `currentUserId.equals(creator.getId()) || currentUserId.equals(assignee.getId())`. Any stranger receives `403 Forbidden` (`You do not have permission to access this task`).
+ - **Assignment and Deletion**: Restricted exclusively to the task creator (`currentUserId.equals(creator.getId())`).
+3. **Multi-Tenant Search & Query Scoping (`TaskSpecification`)**:
+ - The query search endpoint (`GET /api/v1/tasks`) extracts `currentUserId` directly from the validated `@AuthenticationPrincipal Jwt`.
+ - **Explicit Team Query (`teamId != null`)**: The user's membership in the target team is verified before executing the query. If the user is not a member, `403 Forbidden` is returned immediately.
+ - **Global Search (`teamId == null`)**: Dynamic JPA Specification constructs a database-level isolation predicate:
+ ```sql
+ WHERE (team_id IN (:allowedTeamIds))
+ OR (team_id IS NULL AND (created_by = :currentUserId OR assignee_id = :currentUserId))
+ ```
+ Users can only discover tasks belonging to teams they have joined, plus their own personal tasks. Strangers' personal tasks and unjoined team backlogs are entirely invisible.
+4. **Subsystem Authorization Parity**:
+ - **Collaboration (Comments & Attachments)**: `TaskCommentService` and `TaskAttachmentService` execute the identical team membership and personal task access checks prior to reading or modifying discussions and binary files.
+ - **AI Assistant**: `AiAssistantService.summarizeTask` validates task access, and `detectDuplicates` scopes candidate similarity comparisons strictly to user-accessible tasks and joined teams.
+
+### 4.3 STRIDE Threat Modeling Analysis
+
+The platform attack surface is systematically modeled against the **STRIDE** methodology for collaborative task and multi-tenant platforms:
+
+| Threat Category | Threat Description | Attack Vector | TaskMaster Architectural Mitigation | Architectural Reference |
+| :--- | :--- | :--- | :--- | :--- |
+| **Spoofing** | Forged user identity or access token tampering | Submitting requests with modified JWT payload or expired claims | Asymmetric RS256 signature verification with 2048-bit RSA PEM key pairs; public key discovery via RFC 7517 JWKS; `@AuthenticationPrincipal` extraction from validated JWT subject claim. | [ADR 0001](adr/0001-modular-monolith.md), [ADR 0009](adr/0009-multi-tenant-task-authorization-hardening.md) |
+| **Tampering** | Concurrent write overwrites or unauthorized status changes | Race conditions on concurrent status updates or modifying task payloads | JPA `@Version` optimistic locking (returning RFC 7807 `409 Conflict`); validated `TaskStatus` state machine; transactional boundaries via `@Transactional`. | [ADR 0002](adr/0002-postgresql.md), [ADR 0003](adr/0003-hexagonal-architecture.md) |
+| **Repudiation** | Denying task assignment, status transition, or comment modification | Claiming a task was updated without user consent or unauthorized deletion | Strongly typed domain events (`TaskAssignedEvent`, `TaskStatusChangedEvent`); immutable audit fields (`created_at`, `updated_at`, `AuditAwareImpl`); structured JSON logs with `X-Correlation-ID` and OTel `traceId`. | [ADR 0004](adr/0004-spring-cloud-stream.md), [ADR 0006](adr/0006-opentelemetry-vendor-neutrality.md) |
+| **Information Disclosure** | Insecure Direct Object Reference (IDOR) or cross-tenant snooping | Scanning task UUIDs (`/api/v1/tasks/{id}`) or listing all tasks | Personal tasks are strictly isolated to creator and assignee; team tasks require active team membership; global search queries (`GET /api/v1/tasks`) are dynamically scoped via JPA `TaskSpecification` at the SQL level. | [ADR 0007](adr/0007-free-tier-pluggable-infrastructure.md), [ADR 0009](adr/0009-multi-tenant-task-authorization-hardening.md) |
+| **Denial of Service (DoS)** | Brute force login floods or application thread pool starvation | Rapid auth requests or large file streaming hammering web threads | Redis atomic sliding-window rate limiter with Caffeine bounded LRU fallback; AWS SDK v2 direct pre-signed URL download offloading (zero app-server I/O overhead); Java 25 Virtual Threads. | [ADR 0006](adr/0006-opentelemetry-vendor-neutrality.md), [ADR 0007](adr/0007-free-tier-pluggable-infrastructure.md) |
+| **Elevation of Privilege** | Member attempting administrative actions or deleting workspace | Regular workspace member calling delete team or modifying member roles | Strict RBAC governance (`OWNER`, `ADMIN`, `MEMBER`); team mutation endpoints verify role authority; only workspace `OWNER` can delete team or adjust roles. | [ADR 0001](adr/0001-modular-monolith.md), [ADR 0003](adr/0003-hexagonal-architecture.md) |
+
+### 4.4 Identity & Access Management (IAM) & Endpoint Policy Matrix
+
+| Endpoint | Method | Access Policy | Authentication Required | Enforcement Mechanism |
+| :--- | :--- | :--- | :--- | :--- |
+| `/api/v1/auth/register`, `/login`, `/refresh` | `POST` | Public | No | Sliding-window rate limiter (5 req/min/IP), BCrypt verification, refresh token rotation |
+| `/api/v1/auth/.well-known/jwks.json` | `GET` | Public | No | RFC 7517 RSA public key set |
+| `/api/v1/users/me` | `GET`, `PUT` | Principal-Bound | Yes (`Bearer JWT`) | Retrieves/updates authenticated user's own profile |
+| `/api/v1/teams` | `POST` | Authenticated | Yes (`Bearer JWT`) | Creates workspace and assigns caller as `OWNER` |
+| `/api/v1/teams/{id}` | `GET`, `DELETE` | Team-Bound | Yes (`Bearer JWT`) | Verified via `team_members`; delete restricted to `OWNER` |
+| `/api/v1/teams/join` | `POST` | Authenticated | Yes (`Bearer JWT`) | Validates cryptographic workspace invite code |
+| `/api/v1/tasks` | `GET` | Scoped Multi-Tenant | Yes (`Bearer JWT`) | Scoped to member teams and personal tasks via `TaskSpecification` |
+| `/api/v1/tasks` | `POST` | Authenticated | Yes (`Bearer JWT`) | Associates task with caller; verifies team if `teamId` supplied |
+| `/api/v1/tasks/{id}` | `GET` | Dual-Boundary | Yes (`Bearer JWT`) | Team Member (if team task) OR Creator/Assignee (if personal task) |
+| `/api/v1/tasks/{id}` | `PUT` | Dual-Boundary | Yes (`Bearer JWT`) | Team Member (if team task) OR Creator/Assignee (if personal task) |
+| `/api/v1/tasks/{id}/status` | `PATCH` | Dual-Boundary | Yes (`Bearer JWT`) | Team Member (if team task) OR Creator/Assignee (if personal task) |
+| `/api/v1/tasks/{id}/assign` | `PATCH` | Creator/Team-Bound | Yes (`Bearer JWT`) | Team Member (if team task) OR Creator (if personal task) |
+| `/api/v1/tasks/{id}` | `DELETE` | Creator/Team-Bound | Yes (`Bearer JWT`) | Team Member (if team task) OR Creator (if personal task) |
+| `/api/v1/tasks/{id}/comments` | `POST`, `GET` | Dual-Boundary | Yes (`Bearer JWT`) | Team Member OR Personal Task Creator/Assignee |
+| `/api/v1/tasks/{id}/comments/{cId}` | `PUT`, `DELETE` | Author-Bound | Yes (`Bearer JWT`) | Verified comment author + task access |
+| `/api/v1/tasks/{id}/attachments` | `POST`, `GET` | Dual-Boundary | Yes (`Bearer JWT`) | Team Member OR Personal Task Creator/Assignee |
+| `/api/v1/notifications/**` | `GET`, `PATCH` | Principal-Bound | Yes (`Bearer JWT`) | Restricted to recipient user ID |
+| `/api/v1/ai/**` | `POST` | Dual-Boundary | Yes (`Bearer JWT`) | Validates access to referenced tasks; scopes duplicate detection |
+| `/actuator/health/**`, `/prometheus` | `GET` | Public / Scraping | No | Prometheus scraper and Kubernetes probes |
+| `/swagger-ui/**`, `/v3/api-docs/**` | `GET` | Public | No | Interactive OpenAPI 3.1 documentation |
+
+
---
## 5. Subsystem Sequences & Core Workflows
@@ -463,8 +539,8 @@ flowchart LR
subgraph CONS["Event Consumers"]
direction TB
NotifL["NotificationEventListener\nDB persist + STOMP real-time push"]
- AuditL["AuditLogEventListener\nCompliance & history recording\n(Phase 6)"]
- SearchL["SearchIndexEventListener\nElasticsearch sync\n(Phase 6)"]
+ AuditL["AuditLogEventListener\nTransactional audit log recording\n(Phase 6 & 7)"]
+ SearchL["SearchIndexEventListener\nElasticsearch cluster sync\n(Phase 7)"]
end
TaskAgg --> EV1
@@ -486,6 +562,22 @@ flowchart LR
EV3 --> SearchL
```
+### 6.1 Advanced Search Synchronization Architecture (Phase 7)
+
+In Phase 7, TaskMaster pairs PostgreSQL transactional persistence with an external **Elasticsearch 8.x** cluster for typo-tolerant fuzzy search, completion auto-suggest, and team velocity aggregations:
+
+```mermaid
+flowchart LR
+ MUT["Task Mutation\n(POST/PUT/PATCH)"] --> PG[("PostgreSQL 17\nACID Transaction")]
+ PG --> EVT["Spring ApplicationEvent\nTaskCreated / Updated"]
+ EVT --> LISTENER["SearchIndexEventListener\nTransactional Event Listener"]
+ LISTENER --> ES_QUEUE["Async Indexing Buffer\nVirtual Thread Executor"]
+ ES_QUEUE --> ES[("🔍 Elasticsearch 8.x\ntasks index · fuzzy · aggregations")]
+
+ CLIENT["Client Search Query\nGET /api/v1/search/tasks"] --> ES
+ ES --> RESULTS["Fuzzy Match Results\nHighlighting + Typeahead"]
+```
+
---
## 7. Database Architecture & Entity-Relationship Schema
@@ -733,6 +825,48 @@ flowchart TB
For detailed architectural trade-offs and design rationale, see [ADR 0008: Multi-Platform Cloud, PaaS & Container Deployment Strategy](./adr/0008-multi-platform-cloud-paas-deployment-strategy.md).
+### 10.1 Distributed Resilience & Rate Limiting Engine (Phase 8)
+
+Phase 8 elevates platform stability under extreme load via distributed coordination:
+- **Distributed Redis Rate Limiting**: Atomic sliding-window rate limiting executed via Redis Lua scripts, eliminating race conditions across multiple application replicas.
+- **Resilience4j Circuit Breakers & Timeouts**: Outbound dependencies (LLM APIs, S3 object storage, notification brokers) are protected with configurable failure thresholds (e.g. 50% failure rate over 10 calls trips circuit to `OPEN`).
+- **Idempotency Key Engine**: Mutation requests enforce `Idempotency-Key` headers backed by SHA-256 payload digests to eliminate duplicate state transitions on network retries.
+
+### 10.2 DAG Task Dependency & Workflow Automation Engine (Phase 10)
+
+Phase 10 introduces workflow automation and dependency graphs:
+- **Directed Acyclic Graph (DAG) Engine**: Tasks can declare blocking dependencies (`dependsOnTaskId`). Transitions to `IN_PROGRESS` or `COMPLETED` enforce that prerequisite tasks are completed. Cycle detection is validated at creation time using **Kahn's Algorithm** (topological sort).
+- **Recurring Task Automation**: Scheduled cron triggers automatically generate task instances with dynamic date offsets and assignment rules.
+
+### 10.3 Modern Collaborative Web Application Architecture (Phase 11)
+
+Phase 11 delivers a progressive single-page web application engineered with **Next.js 15 (App Router)** and **React 19**:
+
+```mermaid
+flowchart TB
+ subgraph UI["💻 Next.js 15 / React 19 Frontend"]
+ direction TB
+ ROUTER["App Router\n(Workspaces · Tasks · Settings · Analytics)"]
+ KANBAN["Interactive Kanban Board\n(@dnd-kit Drag-and-Drop)"]
+ EDITOR["Markdown Editor\n(GFM Live Preview & Syntax Highlighting)"]
+ STORES["Client Stores (Zustand)\nAuth Session · Active Workspace · UI State"]
+ QUERY["Server State (TanStack Query v5)\nOptimistic Mutations · Cache Invalidation"]
+ end
+
+ subgraph API_GATE["🔐 Backend REST & WebSocket Ingress"]
+ REST_EP["REST Endpoints (/api/v1/*)\nBearer RS256 JWT Auth"]
+ WS_EP["STOMP Broker (/ws)\nLive /user/queue and /topic/teams"]
+ end
+
+ ROUTER --> KANBAN
+ ROUTER --> EDITOR
+ KANBAN --> QUERY
+ EDITOR --> QUERY
+ QUERY --> REST_EP
+ STORES --> WS_EP
+ WS_EP -. "Live Push" .-> QUERY
+```
+
---
## 11. Technology Baseline Matrix
diff --git a/src/main/java/com/taskmaster/ai/application/service/AiAssistantService.java b/src/main/java/com/taskmaster/ai/application/service/AiAssistantService.java
index aabd049..d7a8941 100644
--- a/src/main/java/com/taskmaster/ai/application/service/AiAssistantService.java
+++ b/src/main/java/com/taskmaster/ai/application/service/AiAssistantService.java
@@ -98,9 +98,7 @@ public SummarizeTaskResponse summarizeTask(UUID taskId, UUID currentUserId) {
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), currentUserId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskAccess(task, currentUserId);
List comments = taskCommentRepository.findRootCommentsByTaskId(taskId);
StringBuilder context = new StringBuilder();
@@ -171,6 +169,10 @@ public DetectDuplicatesResponse detectDuplicates(DetectDuplicatesRequest request
throw new ForbiddenException("You are not a member of this team");
}
+ List allowedTeamIds = request.teamId() == null
+ ? teamMemberRepository.findTeamIdsByUserId(currentUserId)
+ : null;
+
TaskFilterCriteria criteria = new TaskFilterCriteria(
null,
null,
@@ -181,7 +183,9 @@ public DetectDuplicatesResponse detectDuplicates(DetectDuplicatesRequest request
null,
null,
null,
- false
+ false,
+ currentUserId,
+ allowedTeamIds
);
Page matchingTasks = taskRepository.findAll(TaskSpecification.withFilter(criteria), PageRequest.of(0, 5));
@@ -243,4 +247,21 @@ private double calculateSimpleSimilarity(String s1, String s2) {
return Math.min(1.0, (double) common / (double) Math.max(1, Math.min(words1.length, words2.length)));
}
+
+ private void validateTaskAccess(Task task, UUID userId) {
+ if (userId == null) {
+ throw new ForbiddenException("Authentication is required to access this task");
+ }
+ if (task.getTeamId() != null) {
+ if (!teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), userId)) {
+ throw new ForbiddenException("You are not a member of the team this task belongs to");
+ }
+ } else {
+ boolean isCreator = task.getCreator().getId().equals(userId);
+ boolean isAssignee = task.getAssignee() != null && task.getAssignee().getId().equals(userId);
+ if (!isCreator && !isAssignee) {
+ throw new ForbiddenException("You do not have permission to access this personal task");
+ }
+ }
+ }
}
diff --git a/src/main/java/com/taskmaster/collaboration/application/service/TaskAttachmentService.java b/src/main/java/com/taskmaster/collaboration/application/service/TaskAttachmentService.java
index 50b8e33..457b5ad 100644
--- a/src/main/java/com/taskmaster/collaboration/application/service/TaskAttachmentService.java
+++ b/src/main/java/com/taskmaster/collaboration/application/service/TaskAttachmentService.java
@@ -70,9 +70,7 @@ public AttachmentResponse uploadAttachment(UUID taskId, UUID uploaderId, Multipa
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), uploaderId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskAccess(task, uploaderId);
User uploader = userRepository.findById(uploaderId)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", uploaderId));
@@ -116,9 +114,7 @@ public List getTaskAttachments(UUID taskId, UUID currentUser
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), currentUserId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskAccess(task, currentUserId);
List attachments = taskAttachmentRepository.findAllByTaskId(taskId);
return attachments.stream().map(attachmentMapper::toResponse).toList();
@@ -138,4 +134,21 @@ public void deleteAttachment(UUID attachmentId, UUID currentUserId) {
eventPublisher.publish(TaskAttachmentDeletedEvent.of(attachment.getId(), attachment.getTask().getId(), currentUserId));
}
+
+ private void validateTaskAccess(Task task, UUID userId) {
+ if (userId == null) {
+ throw new ForbiddenException("Authentication is required to access attachments for this task");
+ }
+ if (task.getTeamId() != null) {
+ if (!teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), userId)) {
+ throw new ForbiddenException("You are not a member of the team this task belongs to");
+ }
+ } else {
+ boolean isCreator = task.getCreator().getId().equals(userId);
+ boolean isAssignee = task.getAssignee() != null && task.getAssignee().getId().equals(userId);
+ if (!isCreator && !isAssignee) {
+ throw new ForbiddenException("You do not have permission to access attachments on this personal task");
+ }
+ }
+ }
}
diff --git a/src/main/java/com/taskmaster/collaboration/application/service/TaskCommentService.java b/src/main/java/com/taskmaster/collaboration/application/service/TaskCommentService.java
index 170a417..bbab517 100644
--- a/src/main/java/com/taskmaster/collaboration/application/service/TaskCommentService.java
+++ b/src/main/java/com/taskmaster/collaboration/application/service/TaskCommentService.java
@@ -57,9 +57,7 @@ public CommentResponse createComment(UUID taskId, UUID authorId, CreateCommentRe
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), authorId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskAccess(task, authorId);
User author = userRepository.findById(authorId)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", authorId));
@@ -95,9 +93,7 @@ public List getTaskComments(UUID taskId, UUID currentUserId) {
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), currentUserId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskAccess(task, currentUserId);
List rootComments = taskCommentRepository.findRootCommentsByTaskId(taskId);
return rootComments.stream().map(commentMapper::toResponse).toList();
@@ -109,9 +105,7 @@ public CommentResponse updateComment(UUID commentId, UUID currentUserId, UpdateC
.orElseThrow(() -> new ResourceNotFoundException("TaskComment", "id", commentId));
Task task = comment.getTask();
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), currentUserId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskAccess(task, currentUserId);
if (comment.isDeleted()) {
throw new BadRequestException("Cannot edit a deleted comment");
@@ -143,4 +137,21 @@ public void deleteComment(UUID commentId, UUID currentUserId) {
eventPublisher.publish(TaskCommentDeletedEvent.of(comment.getId(), comment.getTask().getId(), currentUserId));
}
+
+ private void validateTaskAccess(Task task, UUID userId) {
+ if (userId == null) {
+ throw new ForbiddenException("Authentication is required to access comments on this task");
+ }
+ if (task.getTeamId() != null) {
+ if (!teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), userId)) {
+ throw new ForbiddenException("You are not a member of the team this task belongs to");
+ }
+ } else {
+ boolean isCreator = task.getCreator().getId().equals(userId);
+ boolean isAssignee = task.getAssignee() != null && task.getAssignee().getId().equals(userId);
+ if (!isCreator && !isAssignee) {
+ throw new ForbiddenException("You do not have permission to access comments on this personal task");
+ }
+ }
+ }
}
diff --git a/src/main/java/com/taskmaster/task/adapter/in/TaskController.java b/src/main/java/com/taskmaster/task/adapter/in/TaskController.java
index 192d26e..b09230b 100644
--- a/src/main/java/com/taskmaster/task/adapter/in/TaskController.java
+++ b/src/main/java/com/taskmaster/task/adapter/in/TaskController.java
@@ -65,7 +65,7 @@ public ResponseEntity> getTaskById(
@AuthenticationPrincipal Jwt jwt,
@PathVariable("id") UUID id
) {
- UUID currentUserId = jwt != null ? UUID.fromString(jwt.getSubject()) : null;
+ UUID currentUserId = UUID.fromString(jwt.getSubject());
TaskResponse response = taskService.getTaskById(id, currentUserId);
return ResponseEntity.ok(ApiResponse.of(response));
}
@@ -73,6 +73,7 @@ public ResponseEntity> getTaskById(
@GetMapping
@Operation(summary = "Search and filter tasks with multi-field sorting and pagination")
public ResponseEntity>> searchTasks(
+ @AuthenticationPrincipal Jwt jwt,
@RequestParam(value = "status", required = false) TaskStatus status,
@RequestParam(value = "priority", required = false) TaskPriority priority,
@RequestParam(value = "assigneeId", required = false) UUID assigneeId,
@@ -84,6 +85,7 @@ public ResponseEntity>> searchTasks(
@RequestParam(value = "label", required = false) String label,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
) {
+ UUID currentUserId = UUID.fromString(jwt.getSubject());
TaskFilterCriteria criteria = new TaskFilterCriteria(
status,
priority,
@@ -97,7 +99,7 @@ public ResponseEntity>> searchTasks(
false
);
- PageResponse response = taskService.searchTasks(criteria, pageable);
+ PageResponse response = taskService.searchTasks(criteria, currentUserId, pageable);
return ResponseEntity.ok(ApiResponse.of(response));
}
diff --git a/src/main/java/com/taskmaster/task/adapter/out/TaskSpecification.java b/src/main/java/com/taskmaster/task/adapter/out/TaskSpecification.java
index 2829435..dbe33e6 100644
--- a/src/main/java/com/taskmaster/task/adapter/out/TaskSpecification.java
+++ b/src/main/java/com/taskmaster/task/adapter/out/TaskSpecification.java
@@ -49,9 +49,24 @@ public static Specification withFilter(TaskFilterCriteria criteria) {
predicates.add(criteriaBuilder.equal(root.get("creator").get("id"), criteria.creatorId()));
}
- // 6. Team filter
+ // 6. Team filter & Tenant Isolation
if (criteria.teamId() != null) {
predicates.add(criteriaBuilder.equal(root.get("teamId"), criteria.teamId()));
+ } else if (criteria.currentUserId() != null) {
+ Predicate personalTasks = criteriaBuilder.and(
+ criteriaBuilder.isNull(root.get("teamId")),
+ criteriaBuilder.or(
+ criteriaBuilder.equal(root.get("creator").get("id"), criteria.currentUserId()),
+ criteriaBuilder.equal(root.get("assignee").get("id"), criteria.currentUserId())
+ )
+ );
+
+ if (criteria.allowedTeamIds() != null && !criteria.allowedTeamIds().isEmpty()) {
+ Predicate teamTasks = root.get("teamId").in(criteria.allowedTeamIds());
+ predicates.add(criteriaBuilder.or(personalTasks, teamTasks));
+ } else {
+ predicates.add(personalTasks);
+ }
}
// 7. Due date range filters
diff --git a/src/main/java/com/taskmaster/task/application/dto/TaskFilterCriteria.java b/src/main/java/com/taskmaster/task/application/dto/TaskFilterCriteria.java
index 2090f3d..38ed282 100644
--- a/src/main/java/com/taskmaster/task/application/dto/TaskFilterCriteria.java
+++ b/src/main/java/com/taskmaster/task/application/dto/TaskFilterCriteria.java
@@ -3,6 +3,7 @@
import com.taskmaster.task.domain.model.TaskPriority;
import com.taskmaster.task.domain.model.TaskStatus;
import java.time.Instant;
+import java.util.List;
import java.util.UUID;
public record TaskFilterCriteria(
@@ -15,5 +16,22 @@ public record TaskFilterCriteria(
Instant dueDateFrom,
Instant dueDateTo,
String label,
- Boolean includeDeleted
-) {}
+ Boolean includeDeleted,
+ UUID currentUserId,
+ List allowedTeamIds
+) {
+ public TaskFilterCriteria(
+ TaskStatus status,
+ TaskPriority priority,
+ UUID assigneeId,
+ UUID creatorId,
+ UUID teamId,
+ String search,
+ Instant dueDateFrom,
+ Instant dueDateTo,
+ String label,
+ Boolean includeDeleted
+ ) {
+ this(status, priority, assigneeId, creatorId, teamId, search, dueDateFrom, dueDateTo, label, includeDeleted, null, null);
+ }
+}
diff --git a/src/main/java/com/taskmaster/task/application/service/TaskService.java b/src/main/java/com/taskmaster/task/application/service/TaskService.java
index 6bab90b..795eb91 100644
--- a/src/main/java/com/taskmaster/task/application/service/TaskService.java
+++ b/src/main/java/com/taskmaster/task/application/service/TaskService.java
@@ -23,6 +23,7 @@
import com.taskmaster.team.domain.port.TeamMemberRepository;
import com.taskmaster.user.domain.model.User;
import com.taskmaster.user.domain.port.UserRepository;
+import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -108,23 +109,41 @@ public TaskResponse getTaskById(UUID taskId, UUID currentUserId) {
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && currentUserId != null) {
- if (!teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), currentUserId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
- }
-
+ validateTaskAccess(task, currentUserId);
return taskMapper.toResponse(task);
}
@Transactional(readOnly = true)
- public TaskResponse getTaskById(UUID taskId) {
- return getTaskById(taskId, null);
- }
+ public PageResponse searchTasks(TaskFilterCriteria criteria, UUID currentUserId, Pageable pageable) {
+ if (currentUserId == null) {
+ throw new ForbiddenException("Authentication is required to search tasks");
+ }
- @Transactional(readOnly = true)
- public PageResponse searchTasks(TaskFilterCriteria criteria, Pageable pageable) {
- Specification spec = TaskSpecification.withFilter(criteria);
+ List allowedTeamIds = null;
+ if (criteria != null && criteria.teamId() != null) {
+ if (!teamMemberRepository.existsByTeamIdAndUserId(criteria.teamId(), currentUserId)) {
+ throw new ForbiddenException("You are not a member of the team specified in the filter");
+ }
+ } else {
+ allowedTeamIds = teamMemberRepository.findTeamIdsByUserId(currentUserId);
+ }
+
+ TaskFilterCriteria scopedCriteria = new TaskFilterCriteria(
+ criteria != null ? criteria.status() : null,
+ criteria != null ? criteria.priority() : null,
+ criteria != null ? criteria.assigneeId() : null,
+ criteria != null ? criteria.creatorId() : null,
+ criteria != null ? criteria.teamId() : null,
+ criteria != null ? criteria.search() : null,
+ criteria != null ? criteria.dueDateFrom() : null,
+ criteria != null ? criteria.dueDateTo() : null,
+ criteria != null ? criteria.label() : null,
+ criteria != null ? criteria.includeDeleted() : false,
+ currentUserId,
+ allowedTeamIds
+ );
+
+ Specification spec = TaskSpecification.withFilter(scopedCriteria);
Page page = taskRepository.findAll(spec, pageable);
return PageResponse.from(page.map(taskMapper::toResponse));
}
@@ -134,9 +153,7 @@ public TaskResponse updateTask(UUID taskId, UUID updaterId, UpdateTaskRequest re
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), updaterId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskAccess(task, updaterId);
task.updateDetails(
request.title(),
@@ -162,9 +179,7 @@ public TaskResponse updateTaskStatus(UUID taskId, UUID changerId, UpdateTaskStat
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), changerId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskAccess(task, changerId);
TaskStatus oldStatus = task.getStatus();
task.updateStatus(request.status());
@@ -187,9 +202,7 @@ public TaskResponse assignTask(UUID taskId, UUID assignerId, AssignTaskRequest r
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), assignerId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskCreatorAccess(task, assignerId, "assign");
if (request.assigneeId() == null) {
task.unassign();
@@ -220,13 +233,43 @@ public void deleteTask(UUID taskId, UUID deleterId) {
Task task = taskRepository.findByIdAndNotDeleted(taskId)
.orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId));
- if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), deleterId)) {
- throw new ForbiddenException("You are not a member of the team this task belongs to");
- }
+ validateTaskCreatorAccess(task, deleterId, "delete");
task.softDelete();
taskRepository.save(task);
taskEventPublisher.publish(TaskDeletedEvent.of(taskId, deleterId));
}
+
+ private void validateTaskAccess(Task task, UUID userId) {
+ if (userId == null) {
+ throw new ForbiddenException("Authentication is required to access this task");
+ }
+ if (task.getTeamId() != null) {
+ if (!teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), userId)) {
+ throw new ForbiddenException("You are not a member of the team this task belongs to");
+ }
+ } else {
+ boolean isCreator = task.getCreator().getId().equals(userId);
+ boolean isAssignee = task.getAssignee() != null && task.getAssignee().getId().equals(userId);
+ if (!isCreator && !isAssignee) {
+ throw new ForbiddenException("You do not have permission to access this personal task");
+ }
+ }
+ }
+
+ private void validateTaskCreatorAccess(Task task, UUID userId, String action) {
+ if (userId == null) {
+ throw new ForbiddenException("Authentication is required to " + action + " this task");
+ }
+ if (task.getTeamId() != null) {
+ if (!teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), userId)) {
+ throw new ForbiddenException("You are not a member of the team this task belongs to");
+ }
+ } else {
+ if (!task.getCreator().getId().equals(userId)) {
+ throw new ForbiddenException("Only the task creator can " + action + " this personal task");
+ }
+ }
+ }
}
diff --git a/src/main/java/com/taskmaster/team/adapter/out/JpaTeamMemberRepository.java b/src/main/java/com/taskmaster/team/adapter/out/JpaTeamMemberRepository.java
index 822655f..8267cf4 100644
--- a/src/main/java/com/taskmaster/team/adapter/out/JpaTeamMemberRepository.java
+++ b/src/main/java/com/taskmaster/team/adapter/out/JpaTeamMemberRepository.java
@@ -36,6 +36,11 @@ public boolean existsByTeamIdAndUserId(UUID teamId, UUID userId) {
return repository.existsByTeamIdAndUserId(teamId, userId);
}
+ @Override
+ public List findTeamIdsByUserId(UUID userId) {
+ return repository.findTeamIdsByUserId(userId);
+ }
+
@Override
public void delete(TeamMember member) {
repository.delete(member);
diff --git a/src/main/java/com/taskmaster/team/adapter/out/SpringDataJpaTeamMemberRepository.java b/src/main/java/com/taskmaster/team/adapter/out/SpringDataJpaTeamMemberRepository.java
index 10b1da1..eb2c588 100644
--- a/src/main/java/com/taskmaster/team/adapter/out/SpringDataJpaTeamMemberRepository.java
+++ b/src/main/java/com/taskmaster/team/adapter/out/SpringDataJpaTeamMemberRepository.java
@@ -5,6 +5,8 @@
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
@@ -15,4 +17,7 @@ public interface SpringDataJpaTeamMemberRepository extends JpaRepository findAllByTeamIdOrderByJoinedAtAsc(UUID teamId);
boolean existsByTeamIdAndUserId(UUID teamId, UUID userId);
+
+ @Query("SELECT tm.team.id FROM TeamMember tm WHERE tm.user.id = :userId")
+ List findTeamIdsByUserId(@Param("userId") UUID userId);
}
diff --git a/src/main/java/com/taskmaster/team/domain/port/TeamMemberRepository.java b/src/main/java/com/taskmaster/team/domain/port/TeamMemberRepository.java
index 1c63ded..8376c85 100644
--- a/src/main/java/com/taskmaster/team/domain/port/TeamMemberRepository.java
+++ b/src/main/java/com/taskmaster/team/domain/port/TeamMemberRepository.java
@@ -15,5 +15,7 @@ public interface TeamMemberRepository {
boolean existsByTeamIdAndUserId(UUID teamId, UUID userId);
+ List findTeamIdsByUserId(UUID userId);
+
void delete(TeamMember member);
}
diff --git a/src/test/java/com/taskmaster/collaboration/application/service/TaskCommentServiceTest.java b/src/test/java/com/taskmaster/collaboration/application/service/TaskCommentServiceTest.java
index 1a331eb..59deac4 100644
--- a/src/test/java/com/taskmaster/collaboration/application/service/TaskCommentServiceTest.java
+++ b/src/test/java/com/taskmaster/collaboration/application/service/TaskCommentServiceTest.java
@@ -119,6 +119,7 @@ void updateComment_WhenAuthor_ShouldUpdate() {
@DisplayName("Should throw ForbiddenException when editing comment by another user")
void updateComment_WhenNotAuthor_ShouldThrowForbidden() {
UUID commentId = UUID.randomUUID();
+ task.assignTo(otherUser);
TaskComment comment = new TaskComment(task, author, null, "Initial comment");
comment.setId(commentId);
@@ -130,6 +131,22 @@ void updateComment_WhenNotAuthor_ShouldThrowForbidden() {
.hasMessageContaining("You can only edit your own comments");
}
+ @Test
+ @DisplayName("Should throw ForbiddenException when stranger tries to edit comment on personal task")
+ void updateComment_WhenStranger_ShouldThrowForbidden() {
+ UUID commentId = UUID.randomUUID();
+ UUID strangerId = UUID.randomUUID();
+ TaskComment comment = new TaskComment(task, author, null, "Initial comment");
+ comment.setId(commentId);
+
+ when(taskCommentRepository.findById(commentId)).thenReturn(Optional.of(comment));
+
+ UpdateCommentRequest request = new UpdateCommentRequest("Edited comment");
+ assertThatThrownBy(() -> commentService.updateComment(commentId, strangerId, request))
+ .isInstanceOf(ForbiddenException.class)
+ .hasMessageContaining("You do not have permission to access comments on this personal task");
+ }
+
@Test
@DisplayName("Should soft delete comment when deleted by author")
void deleteComment_WhenAuthor_ShouldSoftDelete() {
diff --git a/src/test/java/com/taskmaster/collaboration/integration/CollaborationIntegrationTest.java b/src/test/java/com/taskmaster/collaboration/integration/CollaborationIntegrationTest.java
index 42cc648..d45aa91 100644
--- a/src/test/java/com/taskmaster/collaboration/integration/CollaborationIntegrationTest.java
+++ b/src/test/java/com/taskmaster/collaboration/integration/CollaborationIntegrationTest.java
@@ -150,4 +150,53 @@ void fullCollaborationLifecycle() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.message").value("Attachment successfully deleted"));
}
+
+ @Test
+ @DisplayName("Personal Task Collaboration Security: Stranger cannot comment or attach files to personal task")
+ void personalTaskCollaborationSecurity() throws Exception {
+ String email2 = "stranger_" + UUID.randomUUID() + "@example.com";
+ String username2 = "stranger_" + UUID.randomUUID().toString().substring(0, 8);
+ mockMvc.perform(post("/api/v1/auth/register")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(new RegisterRequest(email2, username2, "Password@123", "Stranger"))))
+ .andExpect(status().isCreated());
+
+ MvcResult login2 = mockMvc.perform(post("/api/v1/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(new LoginRequest(username2, "Password@123"))))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ String strangerToken = objectMapper.readTree(login2.getResponse().getContentAsString())
+ .path("data").path("accessToken").asText();
+
+ // 1. Stranger attempts to post comment on User 1's personal task -> 403 Forbidden
+ CreateCommentRequest commentReq = new CreateCommentRequest("Sneaking in a comment", null);
+ mockMvc.perform(post("/api/v1/tasks/" + taskId + "/comments")
+ .header("Authorization", "Bearer " + strangerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(commentReq)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+
+ // 2. Stranger attempts to read comments on User 1's personal task -> 403 Forbidden
+ mockMvc.perform(get("/api/v1/tasks/" + taskId + "/comments")
+ .header("Authorization", "Bearer " + strangerToken))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+
+ // 3. Stranger attempts to upload attachment to User 1's personal task -> 403 Forbidden
+ MockMultipartFile file = new MockMultipartFile("file", "exploit.txt", "text/plain", "data".getBytes());
+ mockMvc.perform(multipart("/api/v1/tasks/" + taskId + "/attachments")
+ .file(file)
+ .header("Authorization", "Bearer " + strangerToken))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+
+ // 4. Stranger attempts to read attachments of User 1's personal task -> 403 Forbidden
+ mockMvc.perform(get("/api/v1/tasks/" + taskId + "/attachments")
+ .header("Authorization", "Bearer " + strangerToken))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+ }
}
diff --git a/src/test/java/com/taskmaster/task/adapter/in/TaskControllerTest.java b/src/test/java/com/taskmaster/task/adapter/in/TaskControllerTest.java
index 9f92959..5543e4f 100644
--- a/src/test/java/com/taskmaster/task/adapter/in/TaskControllerTest.java
+++ b/src/test/java/com/taskmaster/task/adapter/in/TaskControllerTest.java
@@ -16,6 +16,7 @@
import com.taskmaster.shared.config.JacksonConfig;
import com.taskmaster.shared.config.SecurityConfig;
import com.taskmaster.shared.dto.PageResponse;
+import com.taskmaster.shared.exception.ForbiddenException;
import com.taskmaster.shared.exception.GlobalExceptionHandler;
import com.taskmaster.shared.security.JwtConfig;
import com.taskmaster.shared.security.RateLimiterService;
@@ -200,7 +201,7 @@ void searchTasks_ShouldReturnPaginatedTasks() throws Exception {
false
);
- when(taskService.searchTasks(any(TaskFilterCriteria.class), any(Pageable.class))).thenReturn(pageResponse);
+ when(taskService.searchTasks(any(TaskFilterCriteria.class), eq(creatorId), any(Pageable.class))).thenReturn(pageResponse);
mockMvc.perform(get("/api/v1/tasks")
.with(jwt().jwt(builder -> builder.subject(creatorId.toString())))
@@ -212,6 +213,22 @@ void searchTasks_ShouldReturnPaginatedTasks() throws Exception {
.andExpect(jsonPath("$.data.totalElements").value(1));
}
+ @Test
+ @DisplayName("GET /api/v1/tasks should return 403 when user is not member of specified team")
+ void searchTasks_WhenNotTeamMember_ShouldReturn403() throws Exception {
+ UUID userId = UUID.randomUUID();
+ UUID teamId = UUID.randomUUID();
+
+ when(taskService.searchTasks(any(TaskFilterCriteria.class), eq(userId), any(Pageable.class)))
+ .thenThrow(new ForbiddenException("You are not a member of the team specified in the filter"));
+
+ mockMvc.perform(get("/api/v1/tasks")
+ .with(jwt().jwt(builder -> builder.subject(userId.toString())))
+ .param("teamId", teamId.toString()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+ }
+
@Test
@DisplayName("PATCH /api/v1/tasks/{id}/status should update status")
void updateTaskStatus_ShouldReturn200() throws Exception {
diff --git a/src/test/java/com/taskmaster/task/application/service/TaskServiceTest.java b/src/test/java/com/taskmaster/task/application/service/TaskServiceTest.java
index a85b3c0..cd0bf87 100644
--- a/src/test/java/com/taskmaster/task/application/service/TaskServiceTest.java
+++ b/src/test/java/com/taskmaster/task/application/service/TaskServiceTest.java
@@ -6,9 +6,12 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.taskmaster.shared.dto.PageResponse;
+import com.taskmaster.shared.exception.ForbiddenException;
import com.taskmaster.shared.exception.ResourceNotFoundException;
import com.taskmaster.task.application.dto.AssignTaskRequest;
import com.taskmaster.task.application.dto.CreateTaskRequest;
+import com.taskmaster.task.application.dto.TaskFilterCriteria;
import com.taskmaster.task.application.dto.TaskResponse;
import com.taskmaster.task.application.dto.UpdateTaskRequest;
import com.taskmaster.task.application.dto.UpdateTaskStatusRequest;
@@ -21,11 +24,13 @@
import com.taskmaster.task.domain.model.TaskStatus;
import com.taskmaster.task.domain.port.TaskEventPublisher;
import com.taskmaster.task.domain.port.TaskRepository;
+import com.taskmaster.team.domain.port.TeamMemberRepository;
import com.taskmaster.user.application.mapper.UserMapper;
import com.taskmaster.user.domain.model.Role;
import com.taskmaster.user.domain.model.User;
import com.taskmaster.user.domain.port.UserRepository;
import java.time.Instant;
+import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -35,8 +40,9 @@
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-
-import com.taskmaster.team.domain.port.TeamMemberRepository;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
@@ -160,8 +166,86 @@ void getTaskById_WhenNotFound_ShouldThrow() {
UUID taskId = UUID.randomUUID();
when(taskRepository.findByIdAndNotDeleted(taskId)).thenReturn(Optional.empty());
- assertThatThrownBy(() -> taskService.getTaskById(taskId))
+ assertThatThrownBy(() -> taskService.getTaskById(taskId, creator.getId()))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("Task with id '" + taskId + "' not found");
}
+
+ @Test
+ @DisplayName("Should throw ForbiddenException when stranger reads personal task")
+ void getTaskById_WhenPersonalTaskAndStranger_ShouldThrowForbidden() {
+ UUID taskId = UUID.randomUUID();
+ UUID strangerId = UUID.randomUUID();
+ Task personalTask = new Task("Private Task", "Secret", TaskPriority.HIGH, null, creator, null, null, null);
+ personalTask.setId(taskId);
+
+ when(taskRepository.findByIdAndNotDeleted(taskId)).thenReturn(Optional.of(personalTask));
+
+ assertThatThrownBy(() -> taskService.getTaskById(taskId, strangerId))
+ .isInstanceOf(ForbiddenException.class)
+ .hasMessageContaining("You do not have permission to access this personal task");
+ }
+
+ @Test
+ @DisplayName("Should throw ForbiddenException when stranger updates personal task")
+ void updateTask_WhenPersonalTaskAndStranger_ShouldThrowForbidden() {
+ UUID taskId = UUID.randomUUID();
+ UUID strangerId = UUID.randomUUID();
+ Task personalTask = new Task("Private Task", "Secret", TaskPriority.HIGH, null, creator, null, null, null);
+ personalTask.setId(taskId);
+
+ when(taskRepository.findByIdAndNotDeleted(taskId)).thenReturn(Optional.of(personalTask));
+
+ UpdateTaskRequest request = new UpdateTaskRequest("Hacked Title", "Hacked", TaskPriority.LOW, null, null);
+ assertThatThrownBy(() -> taskService.updateTask(taskId, strangerId, request))
+ .isInstanceOf(ForbiddenException.class)
+ .hasMessageContaining("You do not have permission to access this personal task");
+ }
+
+ @Test
+ @DisplayName("Should throw ForbiddenException when stranger deletes personal task")
+ void deleteTask_WhenPersonalTaskAndStranger_ShouldThrowForbidden() {
+ UUID taskId = UUID.randomUUID();
+ UUID strangerId = UUID.randomUUID();
+ Task personalTask = new Task("Private Task", "Secret", TaskPriority.HIGH, null, creator, null, null, null);
+ personalTask.setId(taskId);
+
+ when(taskRepository.findByIdAndNotDeleted(taskId)).thenReturn(Optional.of(personalTask));
+
+ assertThatThrownBy(() -> taskService.deleteTask(taskId, strangerId))
+ .isInstanceOf(ForbiddenException.class)
+ .hasMessageContaining("Only the task creator can delete this personal task");
+ }
+
+ @Test
+ @DisplayName("Should throw ForbiddenException when searching with unjoined team")
+ void searchTasks_WhenUnjoinedTeam_ShouldThrowForbidden() {
+ UUID teamId = UUID.randomUUID();
+ UUID userId = UUID.randomUUID();
+ TaskFilterCriteria criteria = new TaskFilterCriteria(null, null, null, null, teamId, null, null, null, null, false);
+
+ when(teamMemberRepository.existsByTeamIdAndUserId(teamId, userId)).thenReturn(false);
+
+ assertThatThrownBy(() -> taskService.searchTasks(criteria, userId, Pageable.unpaged()))
+ .isInstanceOf(ForbiddenException.class)
+ .hasMessageContaining("You are not a member of the team specified in the filter");
+ }
+
+ @Test
+ @DisplayName("Should scope search to user personal tasks and joined teams when teamId is omitted")
+ void searchTasks_WhenNoTeamSpecified_ShouldScopeSearch() {
+ UUID userId = UUID.randomUUID();
+ UUID team1 = UUID.randomUUID();
+ UUID team2 = UUID.randomUUID();
+
+ when(teamMemberRepository.findTeamIdsByUserId(userId)).thenReturn(List.of(team1, team2));
+ when(taskRepository.findAll(any(Specification.class), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of()));
+
+ PageResponse response = taskService.searchTasks(null, userId, Pageable.ofSize(10));
+
+ assertThat(response).isNotNull();
+ verify(teamMemberRepository).findTeamIdsByUserId(userId);
+ verify(taskRepository).findAll(any(Specification.class), any(Pageable.class));
+ }
}
diff --git a/src/test/java/com/taskmaster/task/integration/TaskIntegrationTest.java b/src/test/java/com/taskmaster/task/integration/TaskIntegrationTest.java
index d90864f..a24c17a 100644
--- a/src/test/java/com/taskmaster/task/integration/TaskIntegrationTest.java
+++ b/src/test/java/com/taskmaster/task/integration/TaskIntegrationTest.java
@@ -208,4 +208,74 @@ void fullTaskLifecycle() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalElements").value(0));
}
+
+ @Test
+ @DisplayName("Multi-Tenant Task Isolation: Stranger is forbidden from accessing or listing personal tasks")
+ void personalTaskIsolation() throws Exception {
+ // User 1 creates a personal task
+ CreateTaskRequest createReq = new CreateTaskRequest(
+ "User 1 Private Secret",
+ "Confidential personal notes",
+ TaskPriority.URGENT,
+ null,
+ null,
+ null,
+ Set.of("private")
+ );
+
+ MvcResult createRes = mockMvc.perform(post("/api/v1/tasks")
+ .header("Authorization", "Bearer " + userToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(createReq)))
+ .andExpect(status().isCreated())
+ .andReturn();
+
+ String taskId = objectMapper.readTree(createRes.getResponse().getContentAsString())
+ .path("data").path("id").asText();
+
+ // 1. User 2 (stranger) attempts to GET User 1's personal task -> 403 Forbidden
+ mockMvc.perform(get("/api/v1/tasks/" + taskId)
+ .header("Authorization", "Bearer " + secondUserToken))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+
+ // 2. User 2 attempts to UPDATE User 1's personal task -> 403 Forbidden
+ UpdateTaskRequest updateReq = new UpdateTaskRequest(
+ "Hacked Title", "Hacked description", TaskPriority.LOW, null, Set.of()
+ );
+ mockMvc.perform(put("/api/v1/tasks/" + taskId)
+ .header("Authorization", "Bearer " + secondUserToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateReq)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+
+ // 3. User 2 attempts to ASSIGN User 1's personal task -> 403 Forbidden
+ mockMvc.perform(patch("/api/v1/tasks/" + taskId + "/assign")
+ .header("Authorization", "Bearer " + secondUserToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(new AssignTaskRequest(secondUserId))))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+
+ // 4. User 2 attempts to DELETE User 1's personal task -> 403 Forbidden
+ mockMvc.perform(delete("/api/v1/tasks/" + taskId)
+ .header("Authorization", "Bearer " + secondUserToken))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+
+ // 5. User 2 calls GET /api/v1/tasks -> User 1's personal task is NOT returned
+ mockMvc.perform(get("/api/v1/tasks")
+ .header("Authorization", "Bearer " + secondUserToken)
+ .param("search", "Private Secret"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalElements").value(0));
+
+ // 6. User 2 tries to search with a random/unjoined teamId -> 403 Forbidden
+ mockMvc.perform(get("/api/v1/tasks")
+ .header("Authorization", "Bearer " + secondUserToken)
+ .param("teamId", UUID.randomUUID().toString()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.status").value(403));
+ }
}