Skip to content

test: improve multi-tenancy coverage#34

Merged
poyrazK merged 27 commits intomainfrom
feature/multi-tenancy
Jan 26, 2026
Merged

test: improve multi-tenancy coverage#34
poyrazK merged 27 commits intomainfrom
feature/multi-tenancy

Conversation

@poyrazK
Copy link
Copy Markdown
Owner

@poyrazK poyrazK commented Jan 26, 2026

Changes

  • Services: Added comprehensive unit tests for (coverage > 85%).
  • Handlers: Added unit tests for (coverage > 90%).
  • Repositories: Extended integration tests for isolation and verified tenant scoping.
  • Domain: Updated to include usage tracking fields.
  • Errors: Added error type.

Coverage Improvements

  • Reached target coverage for all multi-tenancy related components.

Summary by CodeRabbit

  • New Features

    • Full multi-tenant support: create/manage tenants, invite/remove members, switch active tenant; tenant-scoped auth, middleware, membership checks, and quota enforcement across resources.
  • Documentation

    • API docs updated with tenant endpoints and tenant_id / default_tenant_id fields in schemas.
  • Tests

    • Extensive unit, integration, and benchmark coverage for tenant flows, isolation, and quotas.
  • Chores

    • Database migrations to add tenants, memberships, quotas, and tenant_id on resources.

✏️ Tip: You can customize this high-level summary in your review settings.

Copilot AI review requested due to automatic review settings January 26, 2026 11:39
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 26, 2026

Warning

Rate limit exceeded

@PoyrazK has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 0 minutes and 40 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Adds first-class multi-tenancy: tenant domain models, DB migrations, Tenant repository and service, tenant-aware auth and middleware, tenant HTTP handlers and tests, and converts many resource repositories and tests from user-scoped to tenant-scoped ownership and access.

Changes

Cohort / File(s) Summary
Domain models & ports
internal/core/domain/tenant.go, internal/core/ports/tenant.go
New Tenant, TenantMember, TenantQuota types and TenantRepository/TenantService interfaces.
Domain field additions
internal/core/domain/user.go, internal/core/domain/identity.go, internal/core/domain/instance.go, internal/core/domain/vpc.go, internal/core/domain/volume.go, internal/core/domain/security_group.go
Added DefaultTenantID to User and APIKey; added TenantID to Instance, VPC, Volume, SecurityGroup.
Context & errors
internal/core/context/context.go, internal/errors/errors.go
New context helpers WithTenantID / TenantIDFromContext; new error types PermissionDenied and QuotaExceeded.
Services & tests
internal/core/services/tenant.go, internal/core/services/tenant_test.go, internal/core/services/shared_test.go
Implemented TenantService (lifecycle, members, quotas), added unit tests and mocks (MockTenantRepo, MockTenantService).
Auth service wiring
internal/core/services/auth.go, internal/core/services/auth_test.go, internal/core/services/benchmarks_test.go
AuthService now accepts TenantService; registers create personal tenant on user register; tests/benchmarks updated to inject tenant svc (mocks/noop).
HTTP handlers & middleware
internal/handlers/tenant_handler.go, internal/handlers/tenant_handler_test.go, pkg/httputil/auth.go, pkg/httputil/tenant_middleware.go
New TenantHandler (Create/Invite/Switch); auth resolves tenant (header or API key default) and attaches tenantID; added RequireTenant and TenantMember middleware.
Repositories & migrations
internal/repositories/postgres/tenant_repo.go, internal/repositories/postgres/*_repo.go (instances, vpcs, volumes, security_groups, ...), internal/repositories/postgres/migrations/070_*.up.sql, 071_*.up.sql, 072_migrate_to_tenants.up.sql, *_down.sql
New TenantRepo with CRUD/members/quota; many resource repos converted from user_id to tenant_id; migrations add tenants, tenant_members, tenant_quotas, default_tenant_id columns, tenant_id columns, and a migration to populate tenant_id across resources.
Setup & router
internal/api/setup/dependencies.go, internal/api/setup/router.go
Wired Tenant repo/service into DI, added Tenant handler, registered tenant routes, and added tenant middleware to route groups.
Integration & tests
internal/repositories/postgres/multitenancy_integration_test.go, repo-specific tests (instance_repo_test.go, volume_repo_test.go, vpc_repo_unit_test.go, security_group_repo_test.go, ...)
Integration and unit tests updated to tenant-scoped setup, contexts, and assertions; multitenancy integration test reworked to tenant isolation.
Docs & OpenAPI
docs/swagger/*
OpenAPI updates: tenant endpoints and tenant_id/default_tenant_id fields added to multiple schemas; instance name description updated to "Unique per tenant".
Misc tests & util changes
pkg/httputil/*, tests/*, pkg/util/*
Auth middleware and tests updated to accept tenant service; various tests adapted for tenant context and stricter validations.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Handler as TenantHandler
    participant Service as TenantService
    participant Repo as TenantRepository
    participant UserRepo as UserRepository
    participant DB as PostgreSQL

    User->>Handler: POST /tenants (name, slug)
    Handler->>Service: CreateTenant(ctx, name, slug, userID)
    Service->>Repo: GetBySlug(ctx, slug)
    Repo->>DB: SELECT tenants WHERE slug=...
    DB-->>Repo: no rows
    Repo-->>Service: not found
    Service->>Repo: Create(ctx, tenant)
    Repo->>DB: INSERT tenants
    DB-->>Repo: inserted
    Service->>Repo: AddMember(ctx, tenantID, userID, "owner")
    Repo->>DB: INSERT tenant_members
    DB-->>Repo: inserted
    Service->>UserRepo: GetByID(ctx, userID)
    UserRepo->>DB: SELECT users WHERE id=...
    DB-->>UserRepo: user row
    Service->>UserRepo: Update(ctx, default_tenant_id)
    UserRepo->>DB: UPDATE users
    DB-->>UserRepo: updated
    Service-->>Handler: tenant created
    Handler-->>User: 201 Created (tenant)
Loading
sequenceDiagram
    actor Client
    participant Auth as Auth Middleware
    participant TenantMW as TenantMember Middleware
    participant Handler as Resource Handler
    participant Service as TenantService
    participant Repo as TenantRepository
    participant DB as PostgreSQL

    Client->>Auth: Request (API key + optional X-Tenant-ID)
    Auth->>Auth: resolve tenant ID (header or api key default)
    Auth-->>Client: context includes tenantID
    Client->>TenantMW: request proceeds
    TenantMW->>TenantMW: GetUserID & GetTenantID from context
    TenantMW->>Service: GetMembership(ctx, tenantID, userID)
    Service->>Repo: GetMembership(...)
    Repo->>DB: SELECT tenant_members ...
    DB-->>Repo: membership / no rows
    alt membership exists
        TenantMW->>Handler: allow (stores role)
        Handler->>Handler: process tenant-scoped operation
        Handler-->>Client: 200 OK
    else no membership
        TenantMW-->>Client: 403 Forbidden
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I dug a burrow, made a plan,

Tenants bloom across the land,
Members hop and quotas keep,
Resources snug in beds so deep,
Code and carrots — bound by hand! 🥕

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'test: improve multi-tenancy coverage' is overly vague and misleading. While the PR does touch testing, the majority of changes involve implementing comprehensive multi-tenancy features (services, repositories, domain models, handlers, middleware, migrations), not just improving test coverage. Change the title to reflect the main scope: 'feat: implement multi-tenancy support' or 'feat: add tenant management with service layer and database migrations' to accurately represent the extensive feature additions across the codebase.
Docstring Coverage ⚠️ Warning Docstring coverage is 14.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
internal/errors/errors.go (1)

12-22: Map the new error types to HTTP status codes to avoid 500s.

PermissionDenied and QuotaExceeded aren't in pkg/httputil/response.go's statusCodeMap, so they'll fall back to 500. Add mappings (403 and 429 respectively) to keep client behavior consistent.

🛠️ Suggested fix in pkg/httputil/response.go
 statusCodeMap := map[errors.Type]int{
     errors.NotFound:              http.StatusNotFound,
     errors.InvalidInput:          http.StatusBadRequest,
     errors.Unauthorized:          http.StatusUnauthorized,
     errors.Forbidden:             http.StatusForbidden,
+    errors.PermissionDenied:      http.StatusForbidden,
+    errors.QuotaExceeded:         http.StatusTooManyRequests,
     errors.Conflict:              http.StatusConflict,
     errors.BucketNotFound:        http.StatusNotFound,
     errors.ObjectNotFound:        http.StatusNotFound,
     errors.ObjectTooLarge:        http.StatusRequestEntityTooLarge,
     errors.InstanceNotRunning:    http.StatusConflict,
     errors.PortConflict:          http.StatusConflict,
     errors.TooManyPorts:          http.StatusConflict,
     errors.ResourceLimitExceeded: http.StatusTooManyRequests,
 }
internal/api/setup/router.go (2)

217-252: Add RequireTenant and TenantMember middleware to snapshot, image, and cluster routes; update their repositories to filter by tenant.

The migration added tenant_id columns and created tenant indexes for snapshots, images, and clusters, indicating these resources should be tenant-scoped. However:

  1. The routes only apply httputil.Auth(svcs.Identity) without httputil.RequireTenant() and httputil.TenantMember(svcs.Tenant) (inconsistent with instances and VPCs)
  2. The repositories still filter by UserID only and do not use TenantIDFromContext, meaning the tenant_id column is never queried

Update the middleware on these route groups and refactor their repositories to enforce tenant isolation like instances and VPCs do.


268-298: Add RequireTenant() and TenantMember() middleware to subnet, security-groups, and lb route groups.

These routes lack tenant isolation middleware despite migration 071 adding tenant_id to subnets, security_groups, and load_balancers tables. The subnet and load balancer repositories only filter by user_id, while the security group repository attempts to filter by tenant but has no middleware to populate the tenant context. Follow the established pattern used on instanceGroup, vpcGroup, storageGroup, and volumeGroup.

internal/repositories/postgres/security_group_repo.go (1)

232-244: Missing tenant filtering in ListInstanceGroups - potential cross-tenant data leak.

This method does not filter by tenant_id, unlike other methods in this file. If an attacker provides an instanceID belonging to another tenant, they could enumerate security groups from that tenant.

🔒 Suggested fix
 func (r *SecurityGroupRepository) ListInstanceGroups(ctx context.Context, instanceID uuid.UUID) ([]*domain.SecurityGroup, error) {
+	tenantID := appcontext.TenantIDFromContext(ctx)
 	query := `
 		SELECT sg.id, sg.user_id, sg.tenant_id, sg.vpc_id, sg.name, sg.description, sg.arn, sg.created_at 
 		FROM security_groups sg
 		JOIN instance_security_groups isg ON sg.id = isg.group_id
-		WHERE isg.instance_id = $1
+		JOIN instances i ON isg.instance_id = i.id
+		WHERE isg.instance_id = $1 AND i.tenant_id = $2 AND sg.tenant_id = $2
 	`
-	rows, err := r.db.Query(ctx, query, instanceID)
+	rows, err := r.db.Query(ctx, query, instanceID, tenantID)
 	if err != nil {
 		return nil, err
 	}
 	return r.scanSecurityGroups(rows)
 }
🤖 Fix all issues with AI agents
In `@internal/core/services/tenant.go`:
- Around line 70-75: The update call to s.userRepo.Update is currently ignored
with `_ = ...`, which causes silent failures; replace this with proper error
handling in the tenant creation flow: after retrieving the user with
s.userRepo.GetByID and setting user.DefaultTenantID = &tenant.ID, call
s.userRepo.Update(ctx, user) and check its returned error (e.g., err :=
s.userRepo.Update(...)); if non-nil, handle it consistently with surrounding
code (return the error or log it via the service logger) so failures are
surfaced instead of discarded; ensure you reference s.userRepo.Update,
s.userRepo.GetByID, user.DefaultTenantID, ownerID and tenant.ID when making the
change.
- Around line 152-154: The switch default currently returns nil which silently
allows unknown resource types to pass quota checks; update the default case in
the switch inside the quota-checking function (the switch handling the resource
string) to return a non-nil error (e.g., fmt.Errorf("unknown resource type: %q",
resource)) and/or emit a warning via the logger (processLogger or tenant service
logger) so typos or unsupported resources no longer bypass quota enforcement;
ensure callers propagate this error instead of assuming success.

In `@internal/repositories/postgres/migrations/070_create_tenants.down.sql`:
- Around line 1-4: The down migration is missing removal of the
api_keys.default_tenant_id column; update the rollback SQL in
070_create_tenants.down.sql to mirror the users change by dropping
default_tenant_id from the api_keys table (use the same DROP COLUMN IF EXISTS
pattern), ensuring the schema is reverted for both users.default_tenant_id and
api_keys.default_tenant_id.

In `@internal/repositories/postgres/migrations/072_migrate_to_tenants.up.sql`:
- Around line 3-14: The slug generation in the INSERT SELECT can produce invalid
characters and collisions; update the slug expression to normalize accents and
remove non-alphanumeric characters (e.g., use the unaccent extension and a
regexp_replace to convert runs of non a-z0-9 into single hyphens, trim
leading/trailing hyphens, and lower-case the result) and ensure uniqueness by
appending a deterministic short suffix (for example SUBSTR(id::text,1,8) or a
short random token) if needed; modify the slug expression used in the INSERT
INTO tenants SELECT (the current LOWER(REPLACE(name, ' ', '-')) || '-' ||
SUBSTR(id::text, 1, 8) portion) to use these normalization and uniqueness steps
so produced slugs are URL-safe and collision-resistant.
- Around line 17-20: The INSERT into tenant_members uses ON CONFLICT DO NOTHING
without a conflict target which Postgres requires; update the statement that
inserts from tenants to specify the PRIMARY KEY conflict target by changing ON
CONFLICT DO NOTHING to ON CONFLICT (tenant_id, user_id) DO NOTHING so conflicts
on the tenant_members primary key (tenant_id, user_id) are handled correctly;
locate the INSERT INTO tenant_members ... SELECT id, owner_id, 'owner', NOW()
FROM tenants and add the conflict target (tenant_id, user_id) to the ON CONFLICT
clause.
- Around line 96-99: The INSERT into tenant_quotas uses a bare ON CONFLICT DO
NOTHING which should explicitly target the primary key; update the INSERT ...
SELECT statement that inserts into tenant_quotas to use ON CONFLICT (tenant_id)
DO NOTHING so conflicts are resolved against the tenant_id primary key
(referencing tenant_quotas and tenant_id in the INSERT statement).

In `@internal/repositories/postgres/tenant_repo.go`:
- Around line 162-173: GetQuota currently only reads max_* fields into
domain.TenantQuota leaving Used* zeroed, so quota checks in CheckQuota use stale
zeros; update TenantRepo.GetQuota to populate UsedInstances, UsedVPCs,
UsedStorageGB, UsedMemoryGB, and UsedVCPUs either by (recommended) running
additional COUNT/SUM subqueries (or JOINs) against the relevant resource tables
(e.g., instances, vpcs, volumes) in the same DB call and scanning those results
into q.Used* fields, or (alternatively) remove Used* usage from CheckQuota and
compute usage on-the-fly there; modify the GetQuota method (TenantRepo.GetQuota)
to return a fully populated domain.TenantQuota used by CheckQuota.

In `@internal/repositories/postgres/volume_repo.go`:
- Around line 24-27: Create and Update currently use v.TenantID for writes,
opening a tenant bypass; change both VolumeRepository.Create and
VolumeRepository.Update to derive the tenant ID from the context (the same way
GetByID/GetByName/List/Delete do), use that derived tenantID in the
INSERT/UPDATE parameter list instead of v.TenantID, and return an error if no
tenant is found in context (or otherwise handle a missing tenant consistently
with the read methods).

In `@pkg/httputil/auth.go`:
- Around line 33-51: The Auth() middleware currently accepts X-Tenant-ID and
sets context via appcontext.WithTenantID / c.Set("tenantID") without verifying
the authenticated user's membership; either validate membership inside Auth()
(lookup user/ API key tenants and ensure the parsed tenantID from X-Tenant-ID is
among them before calling appcontext.WithTenantID and c.Set) or enforce that
every tenant-aware route registers TenantMember() (and Keep RequireTenant()
where used) so membership checks run before handlers execute; update Auth() to
perform the membership check against the authenticated principal (API key/user)
or add gating to route registration to ensure TenantMember() is applied for all
endpoints that read tenantID from context.
🧹 Nitpick comments (18)
internal/core/domain/security_group.go (1)

27-38: Consider adding TenantID validation for consistency.

The Validate() method checks that UserID is not uuid.Nil (line 34-36), but the newly added TenantID field lacks the same validation. If TenantID is required for all security groups, consider adding a similar check.

Proposed validation addition
 func (sg *SecurityGroup) Validate() error {
 	if sg.Name == "" {
 		return errors.New("security group name cannot be empty")
 	}
 	if sg.VPCID == uuid.Nil {
 		return errors.New("security group must be associated with a VPC")
 	}
 	if sg.UserID == uuid.Nil {
 		return errors.New("security group must have a user owner")
 	}
+	if sg.TenantID == uuid.Nil {
+		return errors.New("security group must be associated with a tenant")
+	}
 	return nil
 }
pkg/httputil/auth.go (1)

60-71: Rename the local variable to avoid confusion.

The value is a tenant ID, but the variable is named userID. Renaming improves readability.

♻️ Suggested rename
-    userID, ok := val.(uuid.UUID)
+    tenantID, ok := val.(uuid.UUID)
     if !ok {
         return uuid.Nil
     }
-    return userID
+    return tenantID
internal/repositories/postgres/migrations/071_add_tenant_id_to_resources.up.sql (1)

4-66: Consider adding NOT NULL constraint after data migration.

The tenant_id column is added as nullable. While this is necessary for the initial migration to avoid breaking existing rows, consider adding a follow-up migration (after 072_migrate_to_tenants.up.sql populates the data) to add a NOT NULL constraint. This would prevent accidental insertion of resources without tenant association.

Example follow-up migration:

ALTER TABLE instances ALTER COLUMN tenant_id SET NOT NULL;
-- repeat for other tables...
pkg/httputil/tenant_middleware.go (2)

35-40: Consider distinguishing error types in membership check.

Currently, both service errors and missing membership return the same "Forbidden" response. If GetMembership returns an internal error (e.g., database failure), returning "Forbidden" could mislead clients and complicate debugging.

♻️ Suggested refinement
 		membership, err := tenantSvc.GetMembership(c.Request.Context(), tenantID, userID)
-		if err != nil || membership == nil {
+		if err != nil {
+			Error(c, err) // propagate actual error (could be internal)
+			c.Abort()
+			return
+		}
+		if membership == nil {
 			Error(c, errors.New(errors.Forbidden, "not a member of this tenant"))
 			c.Abort()
 			return
 		}

42-43: Consider using a named constant for the context key.

Using "tenantRole" as a string literal works but could lead to typos when accessed elsewhere. A named constant would improve maintainability.

const TenantRoleKey = "tenantRole"
// ...
c.Set(TenantRoleKey, membership.Role)
internal/core/domain/tenant.go (2)

9-19: Consider defining constants for Plan and Status fields.

The Plan and Status fields use magic strings documented in comments. Defining typed constants would improve type safety and prevent typos.

♻️ Suggested constants
// Tenant plans
const (
	PlanFree       = "free"
	PlanPro        = "pro"
	PlanEnterprise = "enterprise"
)

// Tenant statuses
const (
	StatusActive    = "active"
	StatusSuspended = "suspended"
)

21-27: Consider defining constants for Role field.

Similar to the Tenant struct, the Role field uses magic strings. Constants would provide better maintainability.

♻️ Suggested constants
// Tenant member roles
const (
	RoleOwner  = "owner"
	RoleAdmin  = "admin"
	RoleMember = "member"
)
internal/handlers/tenant_handler.go (3)

31-51: Swagger annotations are incomplete.

The Swagger documentation is missing @Accept, @Produce, @Param, @Success, and @Failure directives. Additionally, the router path format should use {id} instead of :id for OpenAPI compliance.

📝 Complete Swagger annotations example
// Create godoc
// `@Summary` Create a new tenant
// `@Tags` Tenant
// `@Accept` json
// `@Produce` json
// `@Security` APIKeyAuth
// `@Param` request body CreateTenantRequest true "Tenant creation request"
// `@Success` 201 {object} domain.Tenant
// `@Failure` 400 {object} errors.Error
// `@Failure` 500 {object} errors.Error
// `@Router` /tenants [post]

53-77: Swagger router path uses incorrect format.

The @Router directive uses :id which is Gin's format, but OpenAPI/Swagger expects {id}.

📝 Proposed fix
-// `@Router` /tenants/:id/members [post]
+// `@Router` /tenants/{id}/members [post]

79-98: Swagger router path uses incorrect format.

Same issue as InviteMember - use {id} instead of :id.

📝 Proposed fix
-// `@Router` /tenants/:id/switch [post]
+// `@Router` /tenants/{id}/switch [post]
internal/handlers/tenant_handler_test.go (2)

68-95: Consider using table-driven tests for better coverage.

Per coding guidelines, table-driven tests are recommended. This would also make it easier to add edge cases.

♻️ Table-driven test example
func TestTenantHandler_Create(t *testing.T) {
	tests := []struct {
		name           string
		body           any
		setupMock      func(*mockTenantService, uuid.UUID)
		expectedStatus int
		expectedBody   string
	}{
		{
			name: "success",
			body: CreateTenantRequest{Name: "My Tenant", Slug: "my-tenant"},
			setupMock: func(svc *mockTenantService, userID uuid.UUID) {
				svc.On("CreateTenant", mock.Anything, "My Tenant", "my-tenant", userID).
					Return(&domain.Tenant{Name: "My Tenant"}, nil)
			},
			expectedStatus: http.StatusCreated,
			expectedBody:   "My Tenant",
		},
		// Add more cases...
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// ... test implementation
		})
	}
}

89-91: Set Content-Type header on POST requests.

JSON binding in Gin may behave differently without the proper Content-Type header. Setting it explicitly ensures consistent behavior.

♻️ Proposed fix
 w := httptest.NewRecorder()
 req, _ := http.NewRequest("POST", "/tenants", bytes.NewBuffer(body))
+req.Header.Set("Content-Type", "application/json")
 r.ServeHTTP(w, req)

This applies to all POST requests in the test file (lines 113, 131, 147, 165, 198, 227).

internal/core/services/tenant_test.go (1)

14-21: Remove unused auditSvc from setup function.

The MockAuditService is created but never used in any test, and TenantService doesn't appear to require it based on the constructor in tenant.go.

♻️ Proposed fix
-func setupTenantServiceTest(_ *testing.T) (*MockTenantRepo, *MockUserRepo, *MockAuditService, *services.TenantService) {
+func setupTenantServiceTest() (*MockTenantRepo, *MockUserRepo, *services.TenantService) {
 	tenantRepo := new(MockTenantRepo)
 	userRepo := new(MockUserRepo)
-	auditSvc := new(MockAuditService)
 	// We pass nil for logger for now, or you can mock it if needed
 	svc := services.NewTenantService(tenantRepo, userRepo, nil)
-	return tenantRepo, userRepo, auditSvc, svc
+	return tenantRepo, userRepo, svc
 }

Then update all test functions to use the simplified return:

-tenantRepo, userRepo, _, svc := setupTenantServiceTest(t)
+tenantRepo, userRepo, svc := setupTenantServiceTest()
internal/core/ports/tenant.go (1)

28-37: Consider adding UpdateTenant and DeleteTenant methods for completeness.

The service interface lacks update and delete operations that are present in the repository. This may be intentional if those operations aren't needed yet, but adding them would provide a complete CRUD interface.

♻️ Suggested additions
 type TenantService interface {
 	CreateTenant(ctx context.Context, name, slug string, ownerID uuid.UUID) (*domain.Tenant, error)
 	GetTenant(ctx context.Context, id uuid.UUID) (*domain.Tenant, error)
+	UpdateTenant(ctx context.Context, id uuid.UUID, name string) (*domain.Tenant, error)
+	DeleteTenant(ctx context.Context, id uuid.UUID) error
 	InviteMember(ctx context.Context, tenantID uuid.UUID, email, role string) error
 	RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error
 	SwitchTenant(ctx context.Context, userID, tenantID uuid.UUID) error
 	CheckQuota(ctx context.Context, tenantID uuid.UUID, resource string, requested int) error
 	GetMembership(ctx context.Context, tenantID, userID uuid.UUID) (*domain.TenantMember, error)
 }
internal/repositories/postgres/migrations/070_create_tenants.up.sql (1)

5-5: Redundant index on slug column.

The slug column already has a UNIQUE constraint (line 5), which automatically creates a unique index in PostgreSQL. The explicit idx_tenants_slug index on line 13 is redundant and wastes storage.

♻️ Suggested fix
 CREATE INDEX idx_tenants_slug ON tenants(slug);
+-- Remove this line: UNIQUE constraint already creates an index

Or simply remove line 13 entirely.

Also applies to: 13-13

internal/core/services/tenant.go (1)

29-33: Discarded error could mask database issues.

The error from GetBySlug is discarded with _, _. If the database is unavailable or times out, this would be silently ignored and the code would proceed to create a potentially duplicate tenant (which would then fail on DB constraint).

Consider distinguishing between "not found" (expected) and actual errors.

♻️ Suggested fix
 	// 1. Check if slug exists
-	existing, _ := s.repo.GetBySlug(ctx, slug)
-	if existing != nil {
+	existing, err := s.repo.GetBySlug(ctx, slug)
+	if err == nil && existing != nil {
 		return nil, errors.New(errors.Conflict, "tenant slug already exists")
 	}
+	// If err is not a NotFound error, it could indicate a DB issue
+	// but we'll let the Create call handle the conflict via DB constraint
internal/repositories/postgres/security_group_repo.go (1)

128-135: Redundant error check after scanSecurityRule.

The scanSecurityRule function already handles pgx.ErrNoRows internally and returns the error. The check for pgx.ErrNoRows on lines 130-131 will never be true because the scan function would have returned a different error type.

♻️ Suggested fix
 	rule, err := r.scanSecurityRule(r.db.QueryRow(ctx, query, ruleID, tenantID))
 	if err != nil {
-		if err == pgx.ErrNoRows {
-			return nil, errors.New(errors.NotFound, "security rule not found")
-		}
-		return nil, errors.Wrap(errors.Internal, "failed to get security rule", err)
+		return nil, err
 	}
 	return &rule, nil

Note: This assumes scanSecurityRule should handle the NotFound case. If not, the error handling should be in scanSecurityRule instead.

internal/repositories/postgres/multitenancy_integration_test.go (1)

21-23: Silent failure on TRUNCATE could mask test setup issues.

Using _, _ = db.Exec discards any errors from the TRUNCATE command. If the database connection fails or tables don't exist, tests would proceed with corrupt state.

♻️ Suggested fix
 	// Clear tables in correct order using TRUNCATE CASCADE to handle circular dependencies
 	ctx := context.Background()
-	_, _ = db.Exec(ctx, "TRUNCATE users, tenants, tenant_members, vpcs, instances, volumes CASCADE")
+	_, err := db.Exec(ctx, "TRUNCATE users, tenants, tenant_members, vpcs, instances, volumes CASCADE")
+	require.NoError(t, err, "failed to truncate tables for test setup")

Comment thread internal/core/services/tenant.go
Comment thread internal/core/services/tenant.go
Comment thread internal/repositories/postgres/migrations/072_migrate_to_tenants.up.sql Outdated
Comment thread internal/repositories/postgres/migrations/072_migrate_to_tenants.up.sql Outdated
Comment thread internal/repositories/postgres/tenant_repo.go
Comment thread internal/repositories/postgres/volume_repo.go Outdated
Comment thread pkg/httputil/auth.go Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces tenant-based multi-tenancy support (tenant context propagation, tenant-scoped data access, and tenant management endpoints), along with updated integration/unit tests and database migrations to support tenant isolation.

Changes:

  • Added tenant context handling in HTTP auth flow plus new tenant membership/require-tenant middleware.
  • Introduced tenant domain model, ports, service, handler, and Postgres repository implementation.
  • Updated core repositories (instances, VPCs, volumes, security groups) and added migrations to tenant-scope persisted resources; expanded integration tests for tenant isolation.

Reviewed changes

Copilot reviewed 33 out of 33 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
pkg/httputil/tenant_middleware.go Adds middleware to require an active tenant and validate tenant membership.
pkg/httputil/auth.go Resolves active tenant from header/default and exposes GetTenantID.
internal/repositories/postgres/vpc_repo.go Scopes VPC persistence/queries by tenant_id.
internal/repositories/postgres/volume_repo.go Scopes volume queries by tenant_id and persists tenant_id.
internal/repositories/postgres/tenant_repo.go New Postgres repository for tenants, members, and quotas.
internal/repositories/postgres/security_group_repo.go Scopes security group and rule access by tenant_id; updates tenant ownership checks.
internal/repositories/postgres/multitenancy_integration_test.go Updates integration test to validate tenant isolation and tenant membership behavior.
internal/repositories/postgres/migrations/070_create_tenants.up.sql Creates tenants/members/quotas tables and adds default tenant columns.
internal/repositories/postgres/migrations/070_create_tenants.down.sql Rollback for tenant schema changes.
internal/repositories/postgres/migrations/071_add_tenant_id_to_resources.up.sql Adds tenant_id columns + indexes across resource tables.
internal/repositories/postgres/migrations/071_add_tenant_id_to_resources.down.sql Rollback for tenant_id columns + indexes.
internal/repositories/postgres/migrations/072_migrate_to_tenants.up.sql Backfills tenants, memberships, default tenant IDs, and resource tenant IDs.
internal/repositories/postgres/instance_repo.go Scopes instance persistence/queries by tenant_id.
internal/handlers/tenant_handler.go New API handler for tenant create/invite/switch endpoints.
internal/handlers/tenant_handler_test.go Unit tests for the new tenant handler endpoints.
internal/errors/errors.go Adds new error types for permissions/quota.
internal/core/services/tenant.go New tenant service for tenant management and quota checking.
internal/core/services/tenant_test.go Unit tests for tenant service behaviors.
internal/core/services/shared_test.go Adds mocks for tenant repo/service used by tests.
internal/core/ports/tenant.go Adds tenant repository/service interfaces.
internal/core/domain/tenant.go Adds tenant, member, and quota domain models.
internal/core/domain/user.go Adds DefaultTenantID to User.
internal/core/domain/identity.go Adds DefaultTenantID to APIKey.
internal/core/domain/instance.go Adds TenantID to Instance.
internal/core/domain/vpc.go Adds TenantID to VPC.
internal/core/domain/volume.go Adds TenantID to Volume.
internal/core/domain/security_group.go Adds TenantID to SecurityGroup.
internal/core/context/context.go Adds tenant ID helpers on request context.
internal/api/setup/dependencies.go Wires tenant repo/service into app dependencies.
internal/api/setup/router.go Adds tenant routes and applies tenant middleware to selected route groups.
Comments suppressed due to low confidence (1)

internal/repositories/postgres/security_group_repo.go:150

  • The not-found message still says “you do not own it”, but the query is now tenant-scoped (sg.tenant_id). This is misleading for clients and harder to debug. Update the message to reflect tenant membership/authorization instead of ownership.
	}
	if res.RowsAffected() == 0 {
		return errors.New(errors.NotFound, "security rule not found or you do not own it")

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +88 to +89
query := `UPDATE volumes SET status = $1, instance_id = $2, backend_path = $3, mount_path = $4, updated_at = $5 WHERE id = $6 AND tenant_id = $7`
_, err := r.db.Exec(ctx, query, string(v.Status), v.InstanceID, v.BackendPath, v.MountPath, v.UpdatedAt, v.ID, v.TenantID)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Update scopes by tenant_id using v.TenantID from the mutable volume object rather than the tenant ID from ctx (while reads/deletes use appcontext.TenantIDFromContext(ctx)). To prevent cross-tenant updates, derive the tenant for authorization from context and not from the request payload/object.

Suggested change
query := `UPDATE volumes SET status = $1, instance_id = $2, backend_path = $3, mount_path = $4, updated_at = $5 WHERE id = $6 AND tenant_id = $7`
_, err := r.db.Exec(ctx, query, string(v.Status), v.InstanceID, v.BackendPath, v.MountPath, v.UpdatedAt, v.ID, v.TenantID)
tenantID := appcontext.TenantIDFromContext(ctx)
query := `UPDATE volumes SET status = $1, instance_id = $2, backend_path = $3, mount_path = $4, updated_at = $5 WHERE id = $6 AND tenant_id = $7`
_, err := r.db.Exec(ctx, query, string(v.Status), v.InstanceID, v.BackendPath, v.MountPath, v.UpdatedAt, v.ID, tenantID)

Copilot uses AI. Check for mistakes.
Comment thread pkg/httputil/auth.go
Comment on lines +60 to +64
// GetTenantID returns the active tenant ID from the request context.
func GetTenantID(c *gin.Context) uuid.UUID {
val, exists := c.Get("tenantID")
if !exists {
return uuid.Nil
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

GetTenantID’s docstring says it reads from the “request context”, but it actually reads from the Gin context key (c.Get("tenantID")). This is misleading for callers and makes it easy to assume appcontext.WithTenantID alone is sufficient. Consider updating the comment and renaming the local var used for the type assertion to tenantID for clarity.

Copilot uses AI. Check for mistakes.
`
now := time.Now()
cmd, err := r.db.Exec(ctx, query, inst.Name, string(inst.Status), now, inst.ContainerID, inst.Ports, inst.VpcID, inst.SubnetID, inst.PrivateIP, inst.OvsPort, inst.ID, inst.Version, inst.UserID)
cmd, err := r.db.Exec(ctx, query, inst.Name, string(inst.Status), now, inst.ContainerID, inst.Ports, inst.VpcID, inst.SubnetID, inst.PrivateIP, inst.OvsPort, inst.ID, inst.Version, inst.TenantID)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Update scopes the WHERE tenant_id = ... clause using inst.TenantID from the mutable domain object rather than the tenant ID from the request context (unlike reads/deletes in this repo). This makes cross-tenant updates easier if a caller supplies a mismatched TenantID. Use appcontext.TenantIDFromContext(ctx) for the WHERE clause and treat inst.TenantID as data, not authorization.

Suggested change
cmd, err := r.db.Exec(ctx, query, inst.Name, string(inst.Status), now, inst.ContainerID, inst.Ports, inst.VpcID, inst.SubnetID, inst.PrivateIP, inst.OvsPort, inst.ID, inst.Version, inst.TenantID)
tenantID := appcontext.TenantIDFromContext(ctx)
cmd, err := r.db.Exec(ctx, query, inst.Name, string(inst.Status), now, inst.ContainerID, inst.Ports, inst.VpcID, inst.SubnetID, inst.PrivateIP, inst.OvsPort, inst.ID, inst.Version, tenantID)

Copilot uses AI. Check for mistakes.
registerNetworkRoutes(r, handlers, services)
registerDataRoutes(r, handlers, services)
registerDevOpsRoutes(r, handlers, services)
registerTenantRoutes(r, handlers, services)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

The PR title/description focus on test coverage improvements, but this change set introduces significant new production functionality (tenants schema, middleware, new service/repo, route wiring). Please update the PR description to reflect the scope (or split into separate PRs) so reviewers can assess risk and rollout/migration implications appropriately.

Copilot uses AI. Check for mistakes.
Comment thread internal/errors/errors.go
Comment on lines 18 to +21
Forbidden Type = "FORBIDDEN"
PermissionDenied Type = "PERMISSION_DENIED"
ResourceLimitExceeded Type = "RESOURCE_LIMIT_EXCEEDED"
QuotaExceeded Type = "QUOTA_EXCEEDED"
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

New error types PermissionDenied and QuotaExceeded are added, but pkg/httputil/response.go’s status mapping doesn’t include them. TenantService.CheckQuota returns errors.QuotaExceeded, which will currently be rendered as HTTP 500. Add explicit HTTP status mappings to keep API responses consistent.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +68
if err := s.repo.UpdateQuota(ctx, quota); err != nil {
// Log but don't fail tenant creation
s.logger.Error("failed to set default quota", "tenant_id", tenant.ID, "err", err)
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

CreateTenant can panic when UpdateQuota fails because s.logger can be nil (e.g., unit tests currently pass nil, and nil loggers are easy to introduce in other wiring). Guard the log call (if s.logger != nil) or default logger in NewTenantService to slog.Default().

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,4 @@
ALTER TABLE users DROP COLUMN IF EXISTS default_tenant_id;
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Rollback is incomplete: the UP migration adds default_tenant_id to api_keys, but the DOWN migration only drops it from users. Dropping tenants will fail while api_keys.default_tenant_id still has a FK to tenants. Add a corresponding ALTER TABLE api_keys DROP COLUMN IF EXISTS default_tenant_id (or drop the FK) in the down migration.

Suggested change
ALTER TABLE users DROP COLUMN IF EXISTS default_tenant_id;
ALTER TABLE users DROP COLUMN IF EXISTS default_tenant_id;
ALTER TABLE api_keys DROP COLUMN IF EXISTS default_tenant_id;

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +76
// 5. Update user's default tenant if not set
user, err := s.userRepo.GetByID(ctx, ownerID)
if err == nil && user.DefaultTenantID == nil {
user.DefaultTenantID = &tenant.ID
_ = s.userRepo.Update(ctx, user)
}

Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

This code attempts to persist user.DefaultTenantID, but the current Postgres UserRepo does not select or update the default_tenant_id column (see internal/repositories/postgres/user_repo.go:55-76). That means default-tenant assignment won’t actually persist. Update UserRepo queries/scans/updates to include default_tenant_id.

Suggested change
// 5. Update user's default tenant if not set
user, err := s.userRepo.GetByID(ctx, ownerID)
if err == nil && user.DefaultTenantID == nil {
user.DefaultTenantID = &tenant.ID
_ = s.userRepo.Update(ctx, user)
}

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +16
DROP INDEX IF EXISTS idx_security_groups_tenant;
DROP INDEX IF EXISTS idx_floating_ips_tenant;
DROP INDEX IF EXISTS idx_clusters_tenant;
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

This DOWN migration drops idx_floating_ips_tenant (and later floating_ips.tenant_id), but the corresponding UP migration never creates those (and there’s no floating_ips table in the migrations set). This will break rollback/migration tests on a clean schema. Remove the floating_ips entries or add matching UP changes.

Copilot uses AI. Check for mistakes.
Comment thread pkg/httputil/auth.go Outdated
Comment on lines +44 to +46
} else if apiKeyObj.DefaultTenantID != nil {
tenantID = *apiKeyObj.DefaultTenantID
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Auth now falls back to apiKeyObj.DefaultTenantID, but the PostgreSQL identity repository doesn’t select/scan default_tenant_id from api_keys (see internal/repositories/postgres/identity_repo.go:38-71). As a result, default-tenant resolution will never work and RequireTenant() will reject requests unless X-Tenant-ID is provided. Update the identity repo queries/scans to include default_tenant_id (nullable UUID) so DefaultTenantID is populated.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@docs/swagger/docs.go`:
- Around line 3705-3745: The Swagger annotations for tenant endpoints are
missing path param and response tags; in internal/handlers/tenant_handler.go add
`@Param` id path string true "Tenant ID" to the InviteMember and SwitchTenant
handlers (refer to functions InviteMember and SwitchTenant) and add appropriate
`@Success` and `@Failure` response annotations to all three tenant handlers
(CreateTenant, InviteMember, SwitchTenant) matching your existing response
schemas (e.g., success 200/201 models and failure 4xx/5xx error models); after
updating the comments, regenerate the docs so docs/swagger/docs.go uses {id} in
paths and contains populated responses.

In `@docs/swagger/swagger.json`:
- Around line 3694-3734: The Swagger paths "/tenants/:id/members" and
"/tenants/:id/switch" use Express-style ":id" which is invalid for Swagger;
change the path keys to "/tenants/{id}/members" and "/tenants/{id}/switch" and
add a path parameter declaration for "id" (in the operations under the "post"
for the Tenant tag) with "name": "id", "in": "path", "required": true and an
appropriate "type"/"schema" (string or integer as used elsewhere) so clients and
parameter binding work correctly; leave the "/tenants" POST as-is.

In `@docs/swagger/swagger.yaml`:
- Around line 3697-3720: Update the tenant paths to use Swagger 2.0 compliant
path parameter syntax and add explicit responses and a path parameter
declaration: replace any occurrences of "/tenants/:id/members" and
"/tenants/:id/switch" with "/tenants/{id}/members" and "/tenants/{id}/switch",
add a "parameters" array for each path including a path parameter named "id"
(in: path, required: true, type: string, description), and replace the empty
"responses: {}" blocks with explicit response objects matching the handlers
(e.g., 201 or 200 for successful creation/invite, 204 or 200 for switch, and at
least a default or 4xx/5xx error response with a schema reference) for the POST
operations under "/tenants", "/tenants/{id}/members", and
"/tenants/{id}/switch".
🧹 Nitpick comments (3)
docs/swagger/swagger.json (3)

4095-4097: Clarify default_tenant_id as server-managed.

To avoid clients treating this as writable, consider adding a description and readOnly.

✍️ Suggested doc tweak
                 "default_tenant_id": {
-                    "type": "string"
+                    "type": "string",
+                    "description": "Server-assigned default tenant for the principal",
+                    "readOnly": true
                 },

Also applies to: 5344-5346


4428-4457: Document tenant_id as non-client-writable in Instance schema.

If tenant_id is derived from auth context, mark it read-only to prevent clients from trying to set it.

📝 Suggested doc tweak
                 "tenant_id": {
-                    "type": "string"
+                    "type": "string",
+                    "description": "Tenant owning the instance (server-assigned)",
+                    "readOnly": true
                 },

5068-5073: Apply the same tenant_id documentation to other resource schemas.

For SecurityGroup/VPC/Volume, consider adding descriptions and readOnly to reduce ambiguity for client SDKs.

🧩 Suggested doc tweak (apply similarly to each schema)
                 "tenant_id": {
-                    "type": "string"
+                    "type": "string",
+                    "description": "Tenant owning the resource (server-assigned)",
+                    "readOnly": true
                 },

Also applies to: 5393-5398, 5435-5443

Comment thread docs/swagger/docs.go
Comment on lines +3705 to +3745
"/tenants": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Create a new tenant",
"responses": {}
}
},
"/tenants/:id/members": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Invite member to tenant",
"responses": {}
}
},
"/tenants/:id/switch": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Switch active tenant",
"responses": {}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and get basic info
ls -la docs/swagger/docs.go
wc -l docs/swagger/docs.go

Repository: PoyrazK/thecloud

Length of output: 157


🏁 Script executed:

# Read the specific lines mentioned in the review (3705-3745)
sed -n '3705,3745p' docs/swagger/docs.go

Repository: PoyrazK/thecloud

Length of output: 1153


🏁 Script executed:

# Also check around those lines for context
sed -n '3700,3750p' docs/swagger/docs.go

Repository: PoyrazK/thecloud

Length of output: 1350


🏁 Script executed:

# Search for tenant-related Swagger annotations in the codebase
fd -e go -type f | xargs grep -l "Tenant" | head -20

Repository: PoyrazK/thecloud

Length of output: 230


🏁 Script executed:

# Look for swagger:operation or swagger:route annotations related to tenants
rg 'swagger:(operation|route).*tenant|/tenants' --type go -A 5 -B 2 | head -100

Repository: PoyrazK/thecloud

Length of output: 6308


🏁 Script executed:

# Check how other endpoints with path params are defined in docs.go
sed -n '1,100p' docs/swagger/docs.go | head -50

Repository: PoyrazK/thecloud

Length of output: 1645


🏁 Script executed:

# Search for another endpoint with path params to compare format
rg '"/[a-z]+/{id}' docs/swagger/docs.go -A 10 | head -50

Repository: PoyrazK/thecloud

Length of output: 165


🏁 Script executed:

# Check the full definition of tenant endpoints including parameters
sed -n '3700,3800p' docs/swagger/docs.go

Repository: PoyrazK/thecloud

Length of output: 3069


🏁 Script executed:

# Look for parameters definition in tenant endpoints
rg '/tenants' docs/swagger/docs.go -A 30 | head -100

Repository: PoyrazK/thecloud

Length of output: 1695


🏁 Script executed:

# Look for other path parameter examples in docs.go
rg '\{id\}' docs/swagger/docs.go -B 2 -A 10 | head -100

Repository: PoyrazK/thecloud

Length of output: 2886


🏁 Script executed:

# Search for instances of path params to see the pattern used
rg '/:id|/{id}' docs/swagger/docs.go | head -20

Repository: PoyrazK/thecloud

Length of output: 159


🏁 Script executed:

# Check the tenant_handler.go file for full swagger annotations
cat -n internal/handlers/tenant_handler.go | grep -A 15 "Create\|InviteMember\|SwitchTenant" | head -80

Repository: PoyrazK/thecloud

Length of output: 2698


🏁 Script executed:

# Search for swagger annotations that have `@Param` for path parameters
rg '@Param.*path' . -t go -B 2 -A 2 | head -50

Repository: PoyrazK/thecloud

Length of output: 2937


🏁 Script executed:

# Look at the complete swagger annotation block for one of the tenant handlers
sed -n '1,200p' internal/handlers/tenant_handler.go

Repository: PoyrazK/thecloud

Length of output: 2551


Add path parameter declarations and responses to tenant endpoint annotations.

The tenant endpoints are missing @Param declarations for the id path parameter and lack @Success/@Failure response annotations. Compare with other handlers like lb_handler.go: they use @Param id path string true "..." for each path parameter and define response schemas. Without these, the generated Swagger spec has empty responses: {} and uses invalid :id path syntax instead of OpenAPI 2.0's {id} format, which breaks client generation and API documentation.

Update internal/handlers/tenant_handler.go lines 53–83 to add:

  • @Param id path string true "Tenant ID" for InviteMember and SwitchTenant
  • @Success and @Failure response annotations for all three endpoints
  • Then regenerate docs/swagger/docs.go
🤖 Prompt for AI Agents
In `@docs/swagger/docs.go` around lines 3705 - 3745, The Swagger annotations for
tenant endpoints are missing path param and response tags; in
internal/handlers/tenant_handler.go add `@Param` id path string true "Tenant ID"
to the InviteMember and SwitchTenant handlers (refer to functions InviteMember
and SwitchTenant) and add appropriate `@Success` and `@Failure` response annotations
to all three tenant handlers (CreateTenant, InviteMember, SwitchTenant) matching
your existing response schemas (e.g., success 200/201 models and failure 4xx/5xx
error models); after updating the comments, regenerate the docs so
docs/swagger/docs.go uses {id} in paths and contains populated responses.

Comment thread docs/swagger/swagger.json
Comment on lines +3694 to +3734
"/tenants": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Create a new tenant",
"responses": {}
}
},
"/tenants/:id/members": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Invite member to tenant",
"responses": {}
}
},
"/tenants/:id/switch": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Switch active tenant",
"responses": {}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix invalid Swagger path templates for tenant routes.

Swagger 2.0 does not recognize :id in path keys, so these endpoints won’t generate correct clients or parameter binding. Use {id} and declare the path parameter.

🔧 Proposed fix
-        "/tenants/:id/members": {
+        "/tenants/{id}/members": {
             "post": {
                 "security": [
                     {
                         "APIKeyAuth": []
                     }
                 ],
                 "tags": [
                     "Tenant"
                 ],
                 "summary": "Invite member to tenant",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Tenant ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
                 "responses": {}
             }
         },
-        "/tenants/:id/switch": {
+        "/tenants/{id}/switch": {
             "post": {
                 "security": [
                     {
                         "APIKeyAuth": []
                     }
                 ],
                 "tags": [
                     "Tenant"
                 ],
                 "summary": "Switch active tenant",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Tenant ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
                 "responses": {}
             }
         }
📝 Committable suggestion

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

Suggested change
"/tenants": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Create a new tenant",
"responses": {}
}
},
"/tenants/:id/members": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Invite member to tenant",
"responses": {}
}
},
"/tenants/:id/switch": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Switch active tenant",
"responses": {}
}
"/tenants": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Create a new tenant",
"responses": {}
}
},
"/tenants/{id}/members": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Invite member to tenant",
"parameters": [
{
"type": "string",
"description": "Tenant ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/tenants/{id}/switch": {
"post": {
"security": [
{
"APIKeyAuth": []
}
],
"tags": [
"Tenant"
],
"summary": "Switch active tenant",
"parameters": [
{
"type": "string",
"description": "Tenant ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
}
🤖 Prompt for AI Agents
In `@docs/swagger/swagger.json` around lines 3694 - 3734, The Swagger paths
"/tenants/:id/members" and "/tenants/:id/switch" use Express-style ":id" which
is invalid for Swagger; change the path keys to "/tenants/{id}/members" and
"/tenants/{id}/switch" and add a path parameter declaration for "id" (in the
operations under the "post" for the Tenant tag) with "name": "id", "in": "path",
"required": true and an appropriate "type"/"schema" (string or integer as used
elsewhere) so clients and parameter binding work correctly; leave the "/tenants"
POST as-is.

Comment thread docs/swagger/swagger.yaml
Comment on lines +3697 to +3720
/tenants:
post:
responses: {}
security:
- APIKeyAuth: []
summary: Create a new tenant
tags:
- Tenant
/tenants/:id/members:
post:
responses: {}
security:
- APIKeyAuth: []
summary: Invite member to tenant
tags:
- Tenant
/tenants/:id/switch:
post:
responses: {}
security:
- APIKeyAuth: []
summary: Switch active tenant
tags:
- Tenant
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and get its size
ls -lh docs/swagger/swagger.yaml

# Get the OpenAPI/Swagger version declared in the file
head -20 docs/swagger/swagger.yaml

# Check the specific lines mentioned (3697-3720)
sed -n '3697,3720p' docs/swagger/swagger.yaml

# Also check a broader context to see if there are other path param examples
sed -n '3700,3750p' docs/swagger/swagger.yaml

Repository: PoyrazK/thecloud

Length of output: 2144


🏁 Script executed:

# Search for existing path parameter patterns in the file
rg "/{[a-zA-Z]" docs/swagger/swagger.yaml -A 2 -B 2 | head -60

# Also search for :id style params to see if this is a consistent pattern
rg ":[a-zA-Z]" docs/swagger/swagger.yaml | head -20

Repository: PoyrazK/thecloud

Length of output: 634


🏁 Script executed:

# Get the full header to see swagger version
head -30 docs/swagger/swagger.yaml

# Search for proper path params with curly braces (need to escape properly)
rg '\{[a-zA-Z]+\}' docs/swagger/swagger.yaml -B 2 | head -100

# Search for :id style params more broadly
rg ':[a-zA-Z]+' docs/swagger/swagger.yaml | head -30

Repository: PoyrazK/thecloud

Length of output: 2827


🏁 Script executed:

# Check how other endpoints with path parameters are defined
rg "parameters:" docs/swagger/swagger.yaml -A 10 | head -100

Repository: PoyrazK/thecloud

Length of output: 2124


🏁 Script executed:

# Verify swagger version declaration
head -5 docs/swagger/swagger.yaml

# Confirm the problematic tenant endpoints exist exactly as shown
sed -n '3697,3720p' docs/swagger/swagger.yaml

Repository: PoyrazK/thecloud

Length of output: 601


Fix Swagger 2.0 compliance for tenant endpoints: use {id} paths, add responses, and declare path parameters.

The tenant endpoints use invalid :id syntax and have incomplete definitions. Swagger 2.0 requires {id} format for path parameters, explicit response codes (not empty responses: {}), and parameter declarations. All other endpoints in this file follow the correct pattern.

🛠️ Proposed fix (adjust status codes/schemas to match handlers)
-  /tenants:
+  /tenants:
     post:
-      responses: {}
+      responses:
+        "201":
+          description: Created
       security:
       - APIKeyAuth: []
       summary: Create a new tenant
       tags:
       - Tenant
-  /tenants/:id/members:
+  /tenants/{id}/members:
     post:
+      parameters:
+      - in: path
+        name: id
+        required: true
+        type: string
-      responses: {}
+      responses:
+        "200":
+          description: OK
       security:
       - APIKeyAuth: []
       summary: Invite member to tenant
       tags:
       - Tenant
-  /tenants/:id/switch:
+  /tenants/{id}/switch:
     post:
+      parameters:
+      - in: path
+        name: id
+        required: true
+        type: string
-      responses: {}
+      responses:
+        "200":
+          description: OK
       security:
       - APIKeyAuth: []
       summary: Switch active tenant
       tags:
       - Tenant
📝 Committable suggestion

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

Suggested change
/tenants:
post:
responses: {}
security:
- APIKeyAuth: []
summary: Create a new tenant
tags:
- Tenant
/tenants/:id/members:
post:
responses: {}
security:
- APIKeyAuth: []
summary: Invite member to tenant
tags:
- Tenant
/tenants/:id/switch:
post:
responses: {}
security:
- APIKeyAuth: []
summary: Switch active tenant
tags:
- Tenant
/tenants:
post:
responses:
"201":
description: Created
security:
- APIKeyAuth: []
summary: Create a new tenant
tags:
- Tenant
/tenants/{id}/members:
post:
parameters:
- in: path
name: id
required: true
type: string
responses:
"200":
description: OK
security:
- APIKeyAuth: []
summary: Invite member to tenant
tags:
- Tenant
/tenants/{id}/switch:
post:
parameters:
- in: path
name: id
required: true
type: string
responses:
"200":
description: OK
security:
- APIKeyAuth: []
summary: Switch active tenant
tags:
- Tenant
🤖 Prompt for AI Agents
In `@docs/swagger/swagger.yaml` around lines 3697 - 3720, Update the tenant paths
to use Swagger 2.0 compliant path parameter syntax and add explicit responses
and a path parameter declaration: replace any occurrences of
"/tenants/:id/members" and "/tenants/:id/switch" with "/tenants/{id}/members"
and "/tenants/{id}/switch", add a "parameters" array for each path including a
path parameter named "id" (in: path, required: true, type: string, description),
and replace the empty "responses: {}" blocks with explicit response objects
matching the handlers (e.g., 201 or 200 for successful creation/invite, 204 or
200 for switch, and at least a default or 4xx/5xx error response with a schema
reference) for the POST operations under "/tenants", "/tenants/{id}/members",
and "/tenants/{id}/switch".

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@internal/core/services/auth.go`:
- Around line 81-114: The user is created before the tenant so a failed
s.tenantSvc.CreateTenant leaves an orphan user; fix by making the two operations
atomic: either (A) use a DB transaction around s.userRepo.Create and
s.tenantSvc.CreateTenant (use your repo/DB transaction functions and commit only
if both succeed, rolling back on error), or (B) implement a compensating delete
by adding and calling a removal method (e.g., userRepo.Delete(ctx, user.ID) or
s.userRepo.DeleteUser/DeleteByID) when s.tenantSvc.CreateTenant returns an error
(ensure Delete exists and handle its error/logging); update the code paths
around s.userRepo.Create and s.tenantSvc.CreateTenant accordingly.
- Around line 87-88: Remove the duplicated comment line "Create Personal Tenant"
so only a single instance of that comment remains; locate the consecutive
duplicate occurrences of the "Create Personal Tenant" comment in internal core
auth service code (the repeated comment above the personal-tenant creation
logic) and delete the extra line to leave one clear comment.
- Around line 116-120: The reload of the user via s.userRepo.GetByID silently
swallows errors which can mask real failures; update the block around
s.userRepo.GetByID(ctx, user.ID) so that when err != nil you either log the
error with context (including user.ID and operation like "reload user after
tenant creation") using the existing logger or propagate the error to the caller
instead of ignoring it; if you choose to propagate, return the err (or wrap it)
from the surrounding function, otherwise call the service logger (e.g., s.logger
or processLogger) with a clear message and keep the original user only after
successful updatedUser assignment.

In `@internal/repositories/postgres/instance_repo_test.go`:
- Around line 85-88: The test's mock for GetByID uses the wrong context and
args: GetByID extracts tenantID from context and queries WHERE id = $1 AND
tenant_id = $2, but the test only sets userID and calls WithArgs(id, userID).
Update the test to set both userID and tenantID in the context (use
appcontext.WithUserID and appcontext.WithTenantID around context.Background()),
change the mock's WithArgs to WithArgs(id, tenantID), and ensure the returned
row includes tenantID (the AddRow call) so mock.ExpectQuery(selectQuery) matches
the tenant-scoped query.
🧹 Nitpick comments (2)
internal/core/services/benchmarks_test.go (1)

160-182: Consider moving NoopTenantService to the noop package for consistency.

Other noop service implementations (NoopIdentityService, NoopAuditService) are defined in internal/repositories/noop/adapters.go. Placing NoopTenantService there would maintain consistency and enable reuse across other test files.

♻️ Suggested location

Move NoopTenantService to internal/repositories/noop/adapters.go alongside the other noop service implementations, then import it here:

-type NoopTenantService struct{}
-
-func (s *NoopTenantService) CreateTenant(ctx context.Context, name, slug string, ownerID uuid.UUID) (*domain.Tenant, error) {
-	return &domain.Tenant{ID: uuid.New()}, nil
-}
-func (s *NoopTenantService) GetTenant(ctx context.Context, id uuid.UUID) (*domain.Tenant, error) {
-	return &domain.Tenant{ID: id}, nil
-}
-func (s *NoopTenantService) InviteMember(ctx context.Context, tenantID uuid.UUID, email, role string) error {
-	return nil
-}
-func (s *NoopTenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
-	return nil
-}
-func (s *NoopTenantService) SwitchTenant(ctx context.Context, userID, tenantID uuid.UUID) error {
-	return nil
-}
-func (s *NoopTenantService) CheckQuota(ctx context.Context, tenantID uuid.UUID, resource string, requested int) error {
-	return nil
-}
-func (s *NoopTenantService) GetMembership(ctx context.Context, tenantID, userID uuid.UUID) (*domain.TenantMember, error) {
-	return &domain.TenantMember{}, nil
-}

Then use &noop.NoopTenantService{} in the benchmarks.

internal/repositories/postgres/instance_repo_test.go (1)

294-299: Consider table-driven cases for delete success/not-found.

This would align the test structure with the project’s testing style and make it easier to add new cases.

As per coding guidelines, please prefer table-driven tests in *_test.go files.

Comment on lines +81 to +114
// Transactionality would be better here, but avoiding for simplicity unless needed
err = s.userRepo.Create(ctx, user)
if err != nil {
return nil, err
}

// Create Personal Tenant
// Create Personal Tenant
tenantName := fmt.Sprintf("%s's Personal Tenant", name)

// Simple slugify: lowercase, replace spaces with hyphens, keep only alphanumeric
slugName := strings.ToLower(name)
slugName = strings.ReplaceAll(slugName, " ", "-")

// Remove non-alphanumeric chars
var cleanSlug strings.Builder
for _, r := range slugName {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
cleanSlug.WriteRune(r)
}
}
slugName = cleanSlug.String()

if slugName == "" {
slugName = "personal"
}
tenantSlug := fmt.Sprintf("personal-%s-%s", slugName, user.ID.String()[:8])

_, err = s.tenantSvc.CreateTenant(ctx, tenantName, tenantSlug, user.ID)
if err != nil {
// TODO: Implement user rollback here if tenant creation fails.
// Currently UserRepository does not expose a Delete method.
return nil, fmt.Errorf("failed to create personal tenant: %w", err)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Orphan user risk if tenant creation fails.

The user is persisted before tenant creation, and if CreateTenant fails, the user remains in the database without a tenant. The TODO comment acknowledges this, but the current behavior could lead to data inconsistencies.

Consider either:

  1. Wrapping both operations in a database transaction.
  2. Implementing a compensating delete operation for the user.
  3. Creating the tenant first (if feasible with your domain constraints).

Do you want me to help design a transactional approach or a compensating action pattern for this flow?

🤖 Prompt for AI Agents
In `@internal/core/services/auth.go` around lines 81 - 114, The user is created
before the tenant so a failed s.tenantSvc.CreateTenant leaves an orphan user;
fix by making the two operations atomic: either (A) use a DB transaction around
s.userRepo.Create and s.tenantSvc.CreateTenant (use your repo/DB transaction
functions and commit only if both succeed, rolling back on error), or (B)
implement a compensating delete by adding and calling a removal method (e.g.,
userRepo.Delete(ctx, user.ID) or s.userRepo.DeleteUser/DeleteByID) when
s.tenantSvc.CreateTenant returns an error (ensure Delete exists and handle its
error/logging); update the code paths around s.userRepo.Create and
s.tenantSvc.CreateTenant accordingly.

Comment on lines +87 to +88
// Create Personal Tenant
// Create Personal Tenant
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove duplicate comment.

The comment "Create Personal Tenant" is duplicated on consecutive lines.

🔧 Proposed fix
-	// Create Personal Tenant
 	// Create Personal Tenant
 	tenantName := fmt.Sprintf("%s's Personal Tenant", name)
📝 Committable suggestion

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

Suggested change
// Create Personal Tenant
// Create Personal Tenant
// Create Personal Tenant
tenantName := fmt.Sprintf("%s's Personal Tenant", name)
🤖 Prompt for AI Agents
In `@internal/core/services/auth.go` around lines 87 - 88, Remove the duplicated
comment line "Create Personal Tenant" so only a single instance of that comment
remains; locate the consecutive duplicate occurrences of the "Create Personal
Tenant" comment in internal core auth service code (the repeated comment above
the personal-tenant creation logic) and delete the extra line to leave one clear
comment.

Comment on lines +116 to +120
// Reload user to reflect changes made during tenant creation (e.g. DefaultTenantID)
updatedUser, err := s.userRepo.GetByID(ctx, user.ID)
if err == nil {
user = updatedUser
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Silent error swallowing on user reload.

If GetByID fails, the error is silently ignored and the original (potentially stale) user object is returned. This could mask database connectivity issues or data inconsistencies.

Consider logging the error or returning it to the caller.

🔧 Proposed fix
 	// Reload user to reflect changes made during tenant creation (e.g. DefaultTenantID)
 	updatedUser, err := s.userRepo.GetByID(ctx, user.ID)
-	if err == nil {
+	if err != nil {
+		// Log but don't fail - user was created successfully
+		// The caller will get the user without DefaultTenantID populated
+		_ = fmt.Errorf("failed to reload user after tenant creation: %w", err)
+	} else {
 		user = updatedUser
 	}

Or alternatively, return the error if this reload is critical:

 	updatedUser, err := s.userRepo.GetByID(ctx, user.ID)
 	if err != nil {
-	if err == nil {
-		user = updatedUser
+		return nil, fmt.Errorf("failed to reload user after tenant creation: %w", err)
 	}
+	user = updatedUser
📝 Committable suggestion

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

Suggested change
// Reload user to reflect changes made during tenant creation (e.g. DefaultTenantID)
updatedUser, err := s.userRepo.GetByID(ctx, user.ID)
if err == nil {
user = updatedUser
}
// Reload user to reflect changes made during tenant creation (e.g. DefaultTenantID)
updatedUser, err := s.userRepo.GetByID(ctx, user.ID)
if err != nil {
// Log but don't fail - user was created successfully
// The caller will get the user without DefaultTenantID populated
_ = fmt.Errorf("failed to reload user after tenant creation: %w", err)
} else {
user = updatedUser
}
🤖 Prompt for AI Agents
In `@internal/core/services/auth.go` around lines 116 - 120, The reload of the
user via s.userRepo.GetByID silently swallows errors which can mask real
failures; update the block around s.userRepo.GetByID(ctx, user.ID) so that when
err != nil you either log the error with context (including user.ID and
operation like "reload user after tenant creation") using the existing logger or
propagate the error to the caller instead of ignoring it; if you choose to
propagate, return the err (or wrap it) from the surrounding function, otherwise
call the service logger (e.g., s.logger or processLogger) with a clear message
and keep the original user only after successful updatedUser assignment.

Comment thread internal/repositories/postgres/instance_repo_test.go Outdated
Copilot AI review requested due to automatic review settings January 26, 2026 12:06
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/httputil/auth.go
Comment on lines +79 to +84
userID, ok := val.(uuid.UUID)
if !ok {
return uuid.Nil
}
return userID
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

GetTenantID assigns the context value to a variable named userID and returns it, which is misleading and makes it easy to accidentally introduce bugs later. Rename the local variable/return variable to tenantID (and keep types consistent) to reflect what is actually being read from the Gin context.

Copilot uses AI. Check for mistakes.
}

// Check if already a member
membership, _ := s.repo.GetMembership(ctx, tenantID, user.ID)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

InviteMember ignores the error returned by repo.GetMembership. If the repo errors (e.g., DB outage), this code will treat it as “not a member” and proceed to add a member, which can hide operational issues and lead to inconsistent state. Propagate/handle the repo error instead of discarding it.

Suggested change
membership, _ := s.repo.GetMembership(ctx, tenantID, user.ID)
membership, err := s.repo.GetMembership(ctx, tenantID, user.ID)
if err != nil {
return err
}

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +175
(SELECT COUNT(*) FROM instances WHERE tenant_id = tq.tenant_id AND status != 'DELETED') as used_instances,
(SELECT COUNT(*) FROM vpcs WHERE tenant_id = tq.tenant_id) as used_vpcs,
(SELECT COALESCE(SUM(size_gb), 0) FROM volumes WHERE tenant_id = tq.tenant_id AND status != 'deleted') as used_storage_gb,
0 as used_memory_gb, -- Placeholder as instance sizing is not yet normalized
0 as used_vcpus -- Placeholder
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

GetQuota filters volumes with status != 'deleted', but domain.VolumeStatus values are uppercase (e.g., AVAILABLE, IN-USE, DELETING). This condition will always be true and will over-count used_storage_gb. Use the correct status value(s) that represent deletion for volumes (or drop the filter if rows are hard-deleted).

Copilot uses AI. Check for mistakes.
Comment thread internal/core/services/tenant_test.go Outdated
Comment on lines +228 to +229
// Unknown resource (should pass or fail depending on logic, implementation returns nil currently)
assert.NoError(t, svc.CheckQuota(ctx, tenantID, "unknown", 1))
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

This test expects CheckQuota(..., "unknown", ...) to succeed, but TenantService.CheckQuota currently returns an InvalidInput error for unknown resource types. Either update the test to expect an error, or change the service behavior to treat unknown resources as no-op (whichever matches the intended API contract).

Suggested change
// Unknown resource (should pass or fail depending on logic, implementation returns nil currently)
assert.NoError(t, svc.CheckQuota(ctx, tenantID, "unknown", 1))
// Unknown resource should be treated as invalid input and return an error
err = svc.CheckQuota(ctx, tenantID, "unknown", 1)
assert.Error(t, err)

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +37
// Create godoc
// @Summary Create a new tenant
// @Tags Tenant
// @Security APIKeyAuth
// @Router /tenants [post]
func (h *TenantHandler) Create(c *gin.Context) {
var req CreateTenantRequest
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

The Swagger annotations for these tenant endpoints omit @Param/@Success/@Failure details (and path params), which results in generated Swagger paths with empty responses blocks. Add the missing annotations so the generated OpenAPI spec includes request/response schemas and status codes.

Copilot uses AI. Check for mistakes.
ALTER TABLE snapshots DROP COLUMN IF EXISTS tenant_id;
ALTER TABLE subnets DROP COLUMN IF EXISTS tenant_id;
ALTER TABLE security_groups DROP COLUMN IF EXISTS tenant_id;
ALTER TABLE floating_ips DROP COLUMN IF EXISTS tenant_id;
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

This down migration runs ALTER TABLE floating_ips ... without checking that the floating_ips table exists. Since the corresponding up migration does not add tenant_id to floating_ips (and the repo doesn’t have a migration creating that table), rolling back can fail on environments without this table. Wrap it in an existence check (similar to the up migration) or remove these statements if floating_ips isn’t part of the schema.

Suggested change
ALTER TABLE floating_ips DROP COLUMN IF EXISTS tenant_id;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'floating_ips'
) THEN
EXECUTE 'ALTER TABLE floating_ips DROP COLUMN IF EXISTS tenant_id';
END IF;
END $$;

Copilot uses AI. Check for mistakes.
Comment on lines 25 to +30
func (r *VpcRepository) Create(ctx context.Context, vpc *domain.VPC) error {
query := `
INSERT INTO vpcs (id, user_id, name, cidr_block, network_id, vxlan_id, status, arn, created_at)
VALUES ($1, $2, $3, NULLIF($4, '')::cidr, $5, $6, $7, $8, $9)
INSERT INTO vpcs (id, user_id, tenant_id, name, cidr_block, network_id, vxlan_id, status, arn, created_at)
VALUES ($1, $2, $3, $4, NULLIF($5, '')::cidr, $6, $7, $8, $9, $10)
`
_, err := r.db.Exec(ctx, query, vpc.ID, vpc.UserID, vpc.Name, vpc.CIDRBlock, vpc.NetworkID, vpc.VXLANID, vpc.Status, vpc.ARN, vpc.CreatedAt)
_, err := r.db.Exec(ctx, query, vpc.ID, vpc.UserID, vpc.TenantID, vpc.Name, vpc.CIDRBlock, vpc.NetworkID, vpc.VXLANID, vpc.Status, vpc.ARN, vpc.CreatedAt)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Create inserts tenant_id using vpc.TenantID. However, the current VpcService.CreateVPC does not populate TenantID (it only sets UserID), so this will typically insert uuid.Nil and fail the FK to tenants(id) or create an unscoped record. Prefer deriving tenant_id from appcontext.TenantIDFromContext(ctx) (and error if missing) or ensure the service always sets vpc.TenantID before calling the repo.

Copilot uses AI. Check for mistakes.
Comment on lines 27 to 35
func (r *InstanceRepository) Create(ctx context.Context, inst *domain.Instance) error {
query := `
INSERT INTO instances (id, user_id, name, image, container_id, status, ports, vpc_id, subnet_id, private_ip, ovs_port, version, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NULLIF($10, '')::inet, $11, $12, $13, $14)
INSERT INTO instances (id, user_id, tenant_id, name, image, container_id, status, ports, vpc_id, subnet_id, private_ip, ovs_port, version, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULLIF($11, '')::inet, $12, $13, $14, $15)
`
_, err := r.db.Exec(ctx, query,
inst.ID, inst.UserID, inst.Name, inst.Image, inst.ContainerID, string(inst.Status), inst.Ports, inst.VpcID, inst.SubnetID,
inst.ID, inst.UserID, inst.TenantID, inst.Name, inst.Image, inst.ContainerID, string(inst.Status), inst.Ports, inst.VpcID, inst.SubnetID,
inst.PrivateIP, inst.OvsPort, inst.Version, inst.CreatedAt, inst.UpdatedAt,
)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Create inserts tenant_id using inst.TenantID, but InstanceService.LaunchInstance currently never sets TenantID on the domain instance. This will usually pass uuid.Nil and break FK constraints / tenant scoping. Use appcontext.TenantIDFromContext(ctx) for the insert (and reject missing tenant), or set inst.TenantID in the service before calling the repo.

Copilot uses AI. Check for mistakes.
Comment on lines 24 to 30
func (r *SecurityGroupRepository) Create(ctx context.Context, sg *domain.SecurityGroup) error {
query := `
INSERT INTO security_groups (id, user_id, vpc_id, name, description, arn, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO security_groups (id, user_id, tenant_id, vpc_id, name, description, arn, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err := r.db.Exec(ctx, query, sg.ID, sg.UserID, sg.VPCID, sg.Name, sg.Description, sg.ARN, sg.CreatedAt)
_, err := r.db.Exec(ctx, query, sg.ID, sg.UserID, sg.TenantID, sg.VPCID, sg.Name, sg.Description, sg.ARN, sg.CreatedAt)
if err != nil {
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Create inserts tenant_id using sg.TenantID, but SecurityGroupService.CreateGroup currently does not set TenantID on the domain object. This will typically insert uuid.Nil, violating the tenant FK and/or creating unusable records. Consider reading the tenant ID from appcontext.TenantIDFromContext(ctx) (and failing fast if missing) or ensure the service always populates sg.TenantID.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
internal/repositories/postgres/instance_repo_test.go (3)

181-188: List test mock is missing tenant_id column in the row schema.

The mock on line 183 defines only 14 columns without tenant_id, but the actual List query (line 84 of instance_repo.go) selects all 15 columns including tenant_id, and scanInstance (line 57 of instance_repo.go) scans them in order. All other tests (GetByID, GetByName, ListBySubnet) correctly include tenant_id as the third column. This mismatch will cause a scan error when the test runs.

Expected fix
WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "tenant_id", "name", "image", "container_id", "status", "ports", "vpc_id", "subnet_id", "private_ip", "ovs_port", "version", "created_at", "updated_at"}).
	AddRow(uuid.New(), userID, uuid.New(), testInstanceName, testInstanceImg, "cid-1", string(domain.StatusRunning), "80:80", nil, nil, testutil.TestIPHost, "ovs-1", 1, now, now))

Add tenant_id to the column list and a uuid.New() to the row values.


32-50: Add TenantID to the instance struct initialization and expected INSERT args in the Create test.

The domain.Instance struct includes a TenantID field (internal/core/domain/instance.go:63), and the repository's Create method (internal/repositories/postgres/instance_repo.go:29-33) includes tenant_id in the INSERT statement and passes inst.TenantID in the query arguments. However, the test fixture doesn't set TenantID on the instance struct or include it in the expected INSERT args, which means the test won't validate tenant isolation on create or catch breaking changes to tenant handling.


130-143: GetByName test needs tenantID in context and mock expectations.

The test sets only userID in context (line 130) but GetByName extracts tenantID via appcontext.TenantIDFromContext(ctx) and queries with WHERE name = $1 AND tenant_id = $2. The mock expects WithArgs(name, userID) but the actual call will be WithArgs(name, tenantID). Update the test to:

  • Set tenantID in context using appcontext.WithTenantID()
  • Update the mock to expect the correct tenantID parameter
🤖 Fix all issues with AI agents
In `@internal/core/services/auth.go`:
- Around line 109-113: The error returned from s.tenantSvc.CreateTenant should
be converted to the service-layer typed internal error instead of returning a
plain fmt.Errorf; update the failure branch in the function containing
s.tenantSvc.CreateTenant to wrap the underlying err using the internal/errors
package (e.g., internal/errors.Wrap or internal/errors.NewInternalError with the
message "failed to create personal tenant") and return that typed error so error
mapping remains consistent.

In `@pkg/httputil/auth.go`:
- Around line 115-118: The current handler masks any error from
tenantSvc.GetMembership(ctx, tenantID, userID) as a Forbidden error; change the
logic in that call site so that if GetMembership returns an error you
wrap/return it as an Internal error (preserving the original error as the cause)
rather than errors.Forbidden, while still returning Forbidden only when the
membership lookup succeeds but indicates no membership; update the return to
include the original err (e.g., wrap with errors.New(errors.Internal, "failed to
verify tenant membership: %w") or equivalent) and keep the member/non-member
check using the member variable from GetMembership.
♻️ Duplicate comments (9)
internal/repositories/postgres/instance_repo_test.go (1)

82-94: Context and mock args still incorrect for tenant-scoped GetByID query.

The context only sets userID (line 82), but per the previous review, GetByID extracts tenantID from context and queries with WHERE id = $1 AND tenant_id = $2. The mock expectation at line 86 still uses WithArgs(id, userID) instead of WithArgs(id, tenantID).

Suggested fix
 		id := uuid.New()
 		userID := uuid.New()
-		ctx := appcontext.WithUserID(context.Background(), userID)
+		tenantID := uuid.New()
+		ctx := appcontext.WithTenantID(appcontext.WithUserID(context.Background(), userID), tenantID)
 		now := time.Now()

 		mock.ExpectQuery(selectQuery).
-			WithArgs(id, userID).
+			WithArgs(id, tenantID).
 			WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "tenant_id", "name", "image", "container_id", "status", "ports", "vpc_id", "subnet_id", "private_ip", "ovs_port", "version", "created_at", "updated_at"}).
-				AddRow(id, userID, uuid.New(), testInstanceName, testInstanceImg, "cid-1", string(domain.StatusRunning), "80:80", nil, nil, testutil.TestIPHost, "ovs-1", 1, now, now))
+				AddRow(id, userID, tenantID, testInstanceName, testInstanceImg, "cid-1", string(domain.StatusRunning), "80:80", nil, nil, testutil.TestIPHost, "ovs-1", 1, now, now))
internal/core/services/tenant.go (3)

28-33: Error from GetBySlug is silently ignored, risking duplicate creation on DB errors.

If the repository returns a real database error (not "not found"), this code incorrectly proceeds to create the tenant. This could mask operational failures or allow duplicate slugs if the check fails due to a transient DB issue.

As per coding guidelines, do not use silent failures.

🐛 Proposed fix
 func (s *TenantService) CreateTenant(ctx context.Context, name, slug string, ownerID uuid.UUID) (*domain.Tenant, error) {
 	// 1. Check if slug exists
-	existing, _ := s.repo.GetBySlug(ctx, slug)
+	existing, err := s.repo.GetBySlug(ctx, slug)
+	if err != nil {
+		// Check if it's a "not found" error (slug available) vs actual DB error
+		var domainErr errors.Error
+		if !stderrors.As(err, &domainErr) || domainErr.Type != errors.NotFound {
+			return nil, errors.Wrap(errors.Internal, "failed to check slug availability", err)
+		}
+	}
 	if existing != nil {
 		return nil, errors.New(errors.Conflict, "tenant slug already exists")
 	}

Note: You'll need to import stderrors "errors" and use your error type checking pattern.


65-68: Potential nil pointer dereference if logger is nil.

If NewTenantService is called with a nil logger (e.g., in tests), calling s.logger.Error() will panic. Consider guarding the log call or defaulting to slog.Default() in the constructor.

🐛 Proposed fix in constructor
 func NewTenantService(repo ports.TenantRepository, userRepo ports.UserRepository, logger *slog.Logger) *TenantService {
+	if logger == nil {
+		logger = slog.Default()
+	}
 	return &TenantService{
 		repo:     repo,
 		userRepo: userRepo,
 		logger:   logger,
 	}
 }

96-100: Error from GetMembership is silently ignored, masking DB failures.

If the repository returns a database error, this code treats it as "not a member" and proceeds with AddMember. This could mask operational issues and lead to inconsistent state.

As per coding guidelines, do not use silent failures.

🐛 Proposed fix
 	// Check if already a member
-	membership, _ := s.repo.GetMembership(ctx, tenantID, user.ID)
+	membership, err := s.repo.GetMembership(ctx, tenantID, user.ID)
+	if err != nil {
+		return errors.Wrap(errors.Internal, "failed to check membership", err)
+	}
 	if membership != nil {
 		return errors.New(errors.Conflict, "user is already a member of this tenant")
 	}
internal/repositories/postgres/tenant_repo.go (1)

162-200: Volume status filter uses incorrect case, causing over-counting.

Line 173 filters volumes with status != 'deleted' (lowercase), but domain.VolumeStatus constants are uppercase (e.g., DELETED, AVAILABLE). This condition will always be true, including deleted volumes in the storage count.

Note the inconsistency: line 171 correctly uses status != 'DELETED' for instances.

🐛 Proposed fix
 			(SELECT COUNT(*) FROM instances WHERE tenant_id = tq.tenant_id AND status != 'DELETED') as used_instances,
 			(SELECT COUNT(*) FROM vpcs WHERE tenant_id = tq.tenant_id) as used_vpcs,
-			(SELECT COALESCE(SUM(size_gb), 0) FROM volumes WHERE tenant_id = tq.tenant_id AND status != 'deleted') as used_storage_gb,
+			(SELECT COALESCE(SUM(size_gb), 0) FROM volumes WHERE tenant_id = tq.tenant_id AND status != 'DELETED') as used_storage_gb,
pkg/httputil/auth.go (1)

54-65: Rename local variable and fix docstring to match Gin context source.

This still reads from Gin context and should use tenantID naming for clarity.

✏️ Suggested fix
-// GetTenantID returns the active tenant ID from the request context.
+// GetTenantID returns the active tenant ID from the Gin context.
 func GetTenantID(c *gin.Context) uuid.UUID {
 	val, exists := c.Get("tenantID")
 	if !exists {
 		return uuid.Nil
 	}
-	userID, ok := val.(uuid.UUID)
+	tenantID, ok := val.(uuid.UUID)
 	if !ok {
 		return uuid.Nil
 	}
-	return userID
+	return tenantID
 }
internal/core/services/auth.go (3)

81-114: Orphan user risk if tenant creation fails.

User is persisted before tenant creation; a failure leaves an orphaned user. Please make the two operations atomic or add compensating deletion.


87-88: Remove the duplicate comment.

🧹 Suggested fix
-	// Create Personal Tenant
 	// Create Personal Tenant

116-120: Don’t silently swallow user reload errors.

Either log the error or return it to the caller to avoid hiding DB failures.

🛠️ Example handling
 	updatedUser, err := s.userRepo.GetByID(ctx, user.ID)
-	if err == nil {
-		user = updatedUser
-	}
+	if err != nil {
+		return nil, fmt.Errorf("failed to reload user after tenant creation: %w", err)
+	}
+	user = updatedUser
🧹 Nitpick comments (6)
tests/edge_cases_test.go (1)

42-60: Consider using named constants and strings.Repeat for boundary tests.

The magic numbers 64 and 65 should be named constants per coding guidelines. Additionally, strings.Repeat is clearer and more efficient than loop concatenation.

♻️ Suggested refactor

Define a constant (ideally imported from the validation package or a shared test constants file):

const MaxInstanceNameLength = 64

Then refactor the test:

 	t.Run("Boundary Values", func(t *testing.T) {
-		// 1. Instance name exactly 64 chars (max allowed)
-		longName := ""
-		for i := 0; i < 64; i++ {
-			longName += "a"
-		}
+		// 1. Instance name exactly at max allowed length
+		longName := strings.Repeat("a", MaxInstanceNameLength)
 		payload := map[string]string{
 			"name":  longName,
 			"image": "alpine",
 		}
 		resp := postRequest(t, client, testutil.TestBaseURL+testutil.TestRouteInstances, token, payload)
 		defer resp.Body.Close()
 		assert.Contains(t, []int{http.StatusCreated, http.StatusAccepted}, resp.StatusCode)

-		// 2. Instance name 65 chars (overflow)
-		tooLongName := longName + "a"
+		// 2. Instance name exceeds max (overflow)
+		tooLongName := strings.Repeat("a", MaxInstanceNameLength+1)
 		payload["name"] = tooLongName
 		resp = postRequest(t, client, testutil.TestBaseURL+testutil.TestRouteInstances, token, payload)
 		defer resp.Body.Close()
 		helpers.AssertErrorCode(t, resp, http.StatusBadRequest)
 	})

Based on learnings, use named constants instead of magic numbers.

pkg/httputil/httputil_test.go (1)

52-80: Consider using consistent mock patterns across all methods.

Most methods in mockTenantService are hard-coded stubs returning nil, while GetMembership properly uses mock.Called. This inconsistency may cause issues when tests need to verify tenant service interactions or return different values.

Based on learnings, use testify/mock for creating mock objects in tests consistently.

♻️ Suggested refactor for consistency
 func (m *mockTenantService) CreateTenant(ctx context.Context, name, slug string, ownerID uuid.UUID) (*domain.Tenant, error) {
-	return nil, nil
+	args := m.Called(ctx, name, slug, ownerID)
+	if args.Get(0) == nil {
+		return nil, args.Error(1)
+	}
+	return args.Get(0).(*domain.Tenant), args.Error(1)
 }
 func (m *mockTenantService) GetTenant(ctx context.Context, id uuid.UUID) (*domain.Tenant, error) {
-	return nil, nil
+	args := m.Called(ctx, id)
+	if args.Get(0) == nil {
+		return nil, args.Error(1)
+	}
+	return args.Get(0).(*domain.Tenant), args.Error(1)
 }

Apply similar changes to InviteMember, RemoveMember, SwitchTenant, and CheckQuota.

internal/repositories/postgres/volume_repo.go (1)

35-45: Consider validating tenant context in read operations for consistency.

Read operations derive tenantID from context but don't validate it. If the context lacks a tenant ID (uuid.Nil), queries will silently return no results rather than failing explicitly. While this is a safe fallback, explicit validation would provide clearer error messages and consistency with Create/Update.

♻️ Optional: Add validation to reads
 func (r *VolumeRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Volume, error) {
 	tenantID := appcontext.TenantIDFromContext(ctx)
+	if tenantID == uuid.Nil {
+		return nil, errors.New(errors.Unauthorized, "tenant ID required in context")
+	}
 	query := `SELECT id, user_id, tenant_id, name, size_gb, status, instance_id, backend_path, mount_path, created_at, updated_at FROM volumes WHERE id = $1 AND tenant_id = $2`
 	return r.scanVolume(r.db.QueryRow(ctx, query, id, tenantID))
 }
internal/repositories/postgres/tenant_repo.go (1)

81-87: Delete doesn't verify the tenant existed.

Unlike VolumeRepository.Delete, this method doesn't check RowsAffected() to return a NotFound error when the tenant doesn't exist. This may be intentional (idempotent deletes), but it's inconsistent with other delete implementations.

♻️ Optional: Add existence check
 func (r *TenantRepo) Delete(ctx context.Context, id uuid.UUID) error {
-	_, err := r.db.Exec(ctx, "DELETE FROM tenants WHERE id = $1", id)
+	cmd, err := r.db.Exec(ctx, "DELETE FROM tenants WHERE id = $1", id)
 	if err != nil {
 		return errors.Wrap(errors.Internal, "failed to delete tenant", err)
 	}
+	if cmd.RowsAffected() == 0 {
+		return errors.New(errors.NotFound, "tenant not found")
+	}
 	return nil
 }
internal/core/services/auth.go (2)

38-45: Use a params struct for the constructor.

The constructor now has 4 dependencies; a params struct keeps call sites stable and readable. As per coding guidelines, use a params struct for 3+ deps.

♻️ Example shape
+type AuthServiceParams struct {
+	UserRepo  ports.UserRepository
+	APIKeySvc ports.IdentityService
+	AuditSvc  ports.AuditService
+	TenantSvc ports.TenantService
+}
+
-func NewAuthService(userRepo ports.UserRepository, apiKeySvc ports.IdentityService, auditSvc ports.AuditService, tenantSvc ports.TenantService) *AuthService {
+func NewAuthService(p AuthServiceParams) *AuthService {
 	return &AuthService{
-		userRepo:       userRepo,
-		apiKeySvc:      apiKeySvc,
-		auditSvc:       auditSvc,
-		tenantSvc:      tenantSvc,
+		userRepo:       p.UserRepo,
+		apiKeySvc:      p.APIKeySvc,
+		auditSvc:       p.AuditSvc,
+		tenantSvc:      p.TenantSvc,
 		failedAttempts: make(map[string]int),
 		lockouts:       make(map[string]time.Time),
 	}
 }

107-107: Replace magic slug length with a named constant.

As per coding guidelines, avoid magic numbers—define a const for the suffix length.

🔧 Example fix
 const (
 	maxFailedAttempts = 5
 	lockoutDuration   = 15 * time.Minute
+	tenantSlugSuffixLen = 8
 )
 ...
-	tenantSlug := fmt.Sprintf("personal-%s-%s", slugName, user.ID.String()[:8])
+	tenantSlug := fmt.Sprintf("personal-%s-%s", slugName, user.ID.String()[:tenantSlugSuffixLen])

Comment on lines +109 to +113
_, err = s.tenantSvc.CreateTenant(ctx, tenantName, tenantSlug, user.ID)
if err != nil {
// TODO: Implement user rollback here if tenant creation fails.
// Currently UserRepository does not expose a Delete method.
return nil, fmt.Errorf("failed to create personal tenant: %w", err)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use typed internal errors for tenant creation failures.

This is a service-layer error and should be wrapped with the internal error taxonomy to keep error mapping consistent. As per coding guidelines, use internal/errors.

🩹 Suggested fix
-		return nil, fmt.Errorf("failed to create personal tenant: %w", err)
+		return nil, errors.Wrap(errors.Internal, "failed to create personal tenant", err)
🤖 Prompt for AI Agents
In `@internal/core/services/auth.go` around lines 109 - 113, The error returned
from s.tenantSvc.CreateTenant should be converted to the service-layer typed
internal error instead of returning a plain fmt.Errorf; update the failure
branch in the function containing s.tenantSvc.CreateTenant to wrap the
underlying err using the internal/errors package (e.g., internal/errors.Wrap or
internal/errors.NewInternalError with the message "failed to create personal
tenant") and return that typed error so error mapping remains consistent.

Comment thread pkg/httputil/auth.go
Comment on lines +115 to +118
member, err := tenantSvc.GetMembership(ctx, tenantID, userID)
if err != nil {
return uuid.Nil, errors.New(errors.Forbidden, "failed to verify tenant membership")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t mask membership lookup failures as Forbidden.

If GetMembership fails due to a backend issue, returning 403 misrepresents the error and complicates debugging. Prefer an Internal error and preserve the cause.

🩹 Suggested fix
-	if err != nil {
-		return uuid.Nil, errors.New(errors.Forbidden, "failed to verify tenant membership")
-	}
+	if err != nil {
+		return uuid.Nil, errors.Wrap(errors.Internal, "failed to verify tenant membership", err)
+	}
📝 Committable suggestion

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

Suggested change
member, err := tenantSvc.GetMembership(ctx, tenantID, userID)
if err != nil {
return uuid.Nil, errors.New(errors.Forbidden, "failed to verify tenant membership")
}
member, err := tenantSvc.GetMembership(ctx, tenantID, userID)
if err != nil {
return uuid.Nil, errors.Wrap(errors.Internal, "failed to verify tenant membership", err)
}
🤖 Prompt for AI Agents
In `@pkg/httputil/auth.go` around lines 115 - 118, The current handler masks any
error from tenantSvc.GetMembership(ctx, tenantID, userID) as a Forbidden error;
change the logic in that call site so that if GetMembership returns an error you
wrap/return it as an Internal error (preserving the original error as the cause)
rather than errors.Forbidden, while still returning Forbidden only when the
membership lookup succeeds but indicates no membership; update the return to
include the original err (e.g., wrap with errors.New(errors.Internal, "failed to
verify tenant membership: %w") or equivalent) and keep the member/non-member
check using the member variable from GetMembership.

Copilot AI review requested due to automatic review settings January 26, 2026 13:01
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@pkg/util/password_test.go`:
- Around line 17-19: Add back a basic randomness assertion to the test: call
GenerateRandomPassword a second time (in the same test in
pkg/util/password_test.go), verify the two returned passwords are not identical
(use the existing test assertion helper like assert.NotEqual or assert.False
with equality), and keep the existing per-character check against passwordChars;
reference GenerateRandomPassword and the passwordChars validation in the same
test so deterministic implementations will fail the test.
🧹 Nitpick comments (1)
pkg/util/password_test.go (1)

10-26: Consider consolidating into a table-driven test.

As per coding guidelines, table-driven tests are preferred. The two test functions could be merged into a single table-driven test for better maintainability and easier extension with additional cases.

Example refactor
func TestGenerateRandomPassword(t *testing.T) {
	tests := []struct {
		name     string
		length   int
		wantLen  int
		wantErr  bool
	}{
		{"standard length", 16, 16, false},
		{"zero length", 0, 0, false},
		{"negative length", -1, 0, false}, // or expect error depending on impl
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			password, err := GenerateRandomPassword(tt.length)
			if tt.wantErr {
				assert.Error(t, err)
				return
			}
			assert.NoError(t, err)
			assert.Len(t, password, tt.wantLen)

			for _, ch := range password {
				assert.True(t, strings.ContainsRune(passwordChars, ch))
			}
		})
	}
}

Based on learnings and coding guidelines, table-driven tests are preferred for test files.

Comment thread pkg/util/password_test.go
Comment on lines +17 to +19
for _, ch := range password {
assert.True(t, strings.ContainsRune(passwordChars, ch), "password contains invalid character")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider restoring the randomness assertion.

The character validation is a good addition, but based on the summary, this change removed the previous randomness check (generating a second password and comparing). Without a randomness assertion, the test no longer verifies that GenerateRandomPassword produces different outputs across calls—a deterministic implementation would pass.

Consider adding back a basic randomness check:

Suggested addition
 	for _, ch := range password {
 		assert.True(t, strings.ContainsRune(passwordChars, ch), "password contains invalid character")
 	}
+
+	// Verify randomness: a second call should produce a different password
+	password2, err := GenerateRandomPassword(length)
+	assert.NoError(t, err)
+	assert.NotEqual(t, password, password2, "expected different passwords on subsequent calls")
 }
📝 Committable suggestion

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

Suggested change
for _, ch := range password {
assert.True(t, strings.ContainsRune(passwordChars, ch), "password contains invalid character")
}
for _, ch := range password {
assert.True(t, strings.ContainsRune(passwordChars, ch), "password contains invalid character")
}
// Verify randomness: a second call should produce a different password
password2, err := GenerateRandomPassword(length)
assert.NoError(t, err)
assert.NotEqual(t, password, password2, "expected different passwords on subsequent calls")
}
🤖 Prompt for AI Agents
In `@pkg/util/password_test.go` around lines 17 - 19, Add back a basic randomness
assertion to the test: call GenerateRandomPassword a second time (in the same
test in pkg/util/password_test.go), verify the two returned passwords are not
identical (use the existing test assertion helper like assert.NotEqual or
assert.False with equality), and keep the existing per-character check against
passwordChars; reference GenerateRandomPassword and the passwordChars validation
in the same test so deterministic implementations will fail the test.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 42 out of 42 changed files in this pull request and generated 13 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/httputil/auth.go
Comment on lines +60 to +64
userID, ok := val.(uuid.UUID)
if !ok {
return uuid.Nil
}
return userID
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

GetTenantID casts the stored value into a variable named userID and returns it. The logic works, but the variable name is misleading and makes future changes error-prone; rename it to tenantID for clarity.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +72
if err := h.svc.InviteMember(c.Request.Context(), tenantID, req.Email, req.Role); err != nil {
httputil.Error(c, err)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

InviteMember calls TenantService.InviteMember for the tenant ID from the URL without verifying that the authenticated user is authorized for that tenant (or that it matches the active tenant in context). Add an explicit authorization check (at minimum: ensure active tenant == :id and user is a member; ideally also enforce role like owner/admin) before invoking the service.

Copilot uses AI. Check for mistakes.
}

if current+requested > limit {
return errors.New(errors.QuotaExceeded, "quota exceeded for "+resource)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

This returns errors.QuotaExceeded, but pkg/httputil.Error does not currently map QUOTA_EXCEEDED to an HTTP status, so clients will receive a 500. Add a mapping for errors.QuotaExceeded (typically 429 or 403) in the HTTP error mapping to avoid incorrect 500 responses.

Suggested change
return errors.New(errors.QuotaExceeded, "quota exceeded for "+resource)
return errors.New(errors.Forbidden, "quota exceeded for "+resource)

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +61
quota := &domain.TenantQuota{
TenantID: tenant.ID,
MaxInstances: 10,
MaxVPCs: 2,
MaxStorageGB: 50,
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Default quota values here differ from the defaults established in migrations (e.g., 070_create_tenants.up.sql / 072_migrate_to_tenants.up.sql). This can lead to surprising behavior depending on whether quotas are created by migration or by service. Consider aligning these defaults or documenting why they intentionally differ.

Copilot uses AI. Check for mistakes.
if err != nil {
// TODO: Implement user rollback here if tenant creation fails.
// Currently UserRepository does not expose a Delete method.
return nil, fmt.Errorf("failed to create personal tenant: %w", err)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

If personal-tenant creation fails, this returns a plain fmt.Errorf(...) which will be treated as an unknown error by httputil.Error (generic 500) and loses the domain error type. Prefer returning a typed internal error (e.g., wrap with your internal/errors type) so it maps consistently and can be surfaced/handled predictably.

Suggested change
return nil, fmt.Errorf("failed to create personal tenant: %w", err)
return nil, errors.New(errors.Internal, "failed to create personal tenant: "+err.Error())

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +18
DROP INDEX IF EXISTS idx_security_groups_tenant;
DROP INDEX IF EXISTS idx_floating_ips_tenant;
DROP INDEX IF EXISTS idx_clusters_tenant;
DROP INDEX IF EXISTS idx_buckets_tenant;
DROP INDEX IF EXISTS idx_scaling_groups_tenant;
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

The down migration drops idx_floating_ips_tenant and floating_ips.tenant_id, but the corresponding up migration doesn’t add tenant_id to floating_ips or create that index. Consider removing these lines or adding the missing up-migration steps so the migration pair is symmetric and easier to reason about.

Copilot uses AI. Check for mistakes.
Comment thread internal/core/services/tenant_test.go Outdated
Comment on lines +228 to +229
// Unknown resource (should pass or fail depending on logic, implementation returns nil currently)
assert.NoError(t, svc.CheckQuota(ctx, tenantID, "unknown", 1))
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

The service implementation returns an InvalidInput error for unknown resources, but this test asserts NoError. Update the expectation to match the current behavior (and optionally assert the error type/message).

Suggested change
// Unknown resource (should pass or fail depending on logic, implementation returns nil currently)
assert.NoError(t, svc.CheckQuota(ctx, tenantID, "unknown", 1))
// Unknown resource should result in an error (invalid input)
err = svc.CheckQuota(ctx, tenantID, "unknown", 1)
assert.Error(t, err)

Copilot uses AI. Check for mistakes.
Comment on lines +133 to +137
mock.ExpectQuery(selectQuery).
WithArgs(name, userID).
WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "name", "image", "container_id", "status", "ports", "vpc_id", "subnet_id", "private_ip", "ovs_port", "version", "created_at", "updated_at"}).
AddRow(id, userID, name, testInstanceImg, "cid-1", string(domain.StatusRunning), "80:80", nil, nil, testutil.TestIPHost, "ovs-1", 1, now, now))
WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "tenant_id", "name", "image", "container_id", "status", "ports", "vpc_id", "subnet_id", "private_ip", "ovs_port", "version", "created_at", "updated_at"}).
AddRow(id, userID, uuid.New(), name, testInstanceImg, "cid-1", string(domain.StatusRunning), "80:80", nil, nil, testutil.TestIPHost, "ovs-1", 1, now, now))
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

InstanceRepository.GetByName is now tenant-scoped, but this test still expects the second query arg to be userID. Update it to provide a tenant ID in context and assert WithArgs(name, tenantID) to match the repository query.

Copilot uses AI. Check for mistakes.
Comment thread internal/errors/errors.go
Comment on lines 18 to 22
Forbidden Type = "FORBIDDEN"
PermissionDenied Type = "PERMISSION_DENIED"
ResourceLimitExceeded Type = "RESOURCE_LIMIT_EXCEEDED"
QuotaExceeded Type = "QUOTA_EXCEEDED"
NotImplemented Type = "NOT_IMPLEMENTED"
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

New error types (PERMISSION_DENIED, QUOTA_EXCEEDED) are introduced here, but the HTTP error mapping in pkg/httputil.Error needs to be updated accordingly; otherwise these errors will fall back to 500 responses. Add explicit status mappings for the new types (and tests) to keep API behavior consistent.

Copilot uses AI. Check for mistakes.
Comment on lines 37 to +41
// GetByID retrieves a single VPC by its UUID and ensures it belongs to the authenticated user.
func (r *VpcRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.VPC, error) {
userID := appcontext.UserIDFromContext(ctx)
query := `SELECT id, user_id, name, COALESCE(cidr_block::text, ''), network_id, vxlan_id, status, arn, created_at FROM vpcs WHERE id = $1 AND user_id = $2`
return r.scanVPC(r.db.QueryRow(ctx, query, id, userID))
tenantID := appcontext.TenantIDFromContext(ctx)
query := `SELECT id, user_id, tenant_id, name, COALESCE(cidr_block::text, ''), network_id, vxlan_id, status, arn, created_at FROM vpcs WHERE id = $1 AND tenant_id = $2`
return r.scanVPC(r.db.QueryRow(ctx, query, id, tenantID))
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

The method comment says this ensures the VPC belongs to the authenticated user, but the implementation now scopes by tenant_id. Update the comment to reflect tenant-scoped access to avoid misleading future changes.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings January 26, 2026 13:38
@sonarqubecloud
Copy link
Copy Markdown

@poyrazK poyrazK merged commit a0eb37d into main Jan 26, 2026
29 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 51 out of 51 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (1)

internal/repositories/postgres/security_group_repo.go:239

  • ListInstanceGroups does not apply any tenant scoping; anyone with an instanceID could potentially enumerate security groups across tenants. This should be filtered by the tenant in ctx (e.g., join instances and require instances.tenant_id = $2, or join security_groups and filter by sg.tenant_id = $2) and pass the tenant ID as a parameter.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


sgGroup := r.Group("/security-groups")
sgGroup.Use(httputil.Auth(svcs.Identity))
sgGroup.Use(httputil.Auth(svcs.Identity, svcs.Tenant))
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

/security-groups routes are tenant-scoped at the repository layer now, but this route group only applies Auth and doesn’t enforce a tenant context (RequireTenant) or membership check (TenantMember) like /vpcs, /instances, /volumes, etc. Without these, handlers may run with tenant_id = uuid.Nil and fail unpredictably or return empty data. Consider adding RequireTenant() and TenantMember(...) here for consistency and clearer errors.

Suggested change
sgGroup.Use(httputil.Auth(svcs.Identity, svcs.Tenant))
sgGroup.Use(httputil.Auth(svcs.Identity, svcs.Tenant), httputil.RequireTenant(), httputil.TenantMember(svcs.Tenant))

Copilot uses AI. Check for mistakes.
Comment on lines +81 to 85
// Transactionality would be better here, but avoiding for simplicity unless needed
err = s.userRepo.Create(ctx, user)
if err != nil {
return nil, err
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Registration now creates the user first and then creates a personal tenant. If tenant creation fails, the method returns an error but leaves the user persisted without a default tenant, which can create an inconsistent account state. Consider wrapping user+tenant creation in a DB transaction or adding a compensating delete/rollback path (and returning a typed internal/errors error instead of a plain fmt.Errorf so HTTP status codes remain accurate).

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +114
_, err = s.tenantSvc.CreateTenant(ctx, tenantName, tenantSlug, user.ID)
if err != nil {
// TODO: Implement user rollback here if tenant creation fails.
// Currently UserRepository does not expose a Delete method.
return nil, fmt.Errorf("failed to create personal tenant: %w", err)
}
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

On tenant creation failure during registration, this returns a plain fmt.Errorf and doesn’t rollback the already-created user. That will surface as an INTERNAL error to API clients and can leave orphaned users. Prefer returning an internal/errors-typed error (e.g. Conflict/InvalidInput/Internal as appropriate) and adding a compensating rollback (or transaction) so registration is atomic.

Copilot uses AI. Check for mistakes.
Comment thread internal/errors/errors.go
Comment on lines +19 to +21
PermissionDenied Type = "PERMISSION_DENIED"
ResourceLimitExceeded Type = "RESOURCE_LIMIT_EXCEEDED"
QuotaExceeded Type = "QUOTA_EXCEEDED"
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

New error types (PermissionDenied, QuotaExceeded) are introduced here, but pkg/httputil/response.go’s statusCodeMap doesn’t include them. As a result, these errors will likely be returned as HTTP 500. Add explicit mappings (e.g. PermissionDenied -> 403, QuotaExceeded -> 429/403 depending on semantics) to keep API responses consistent.

Suggested change
PermissionDenied Type = "PERMISSION_DENIED"
ResourceLimitExceeded Type = "RESOURCE_LIMIT_EXCEEDED"
QuotaExceeded Type = "QUOTA_EXCEEDED"
PermissionDenied Type = Forbidden
ResourceLimitExceeded Type = "RESOURCE_LIMIT_EXCEEDED"
QuotaExceeded Type = ResourceLimitExceeded

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +29
ALTER TABLE images DROP COLUMN IF EXISTS tenant_id;
ALTER TABLE stacks DROP COLUMN IF EXISTS tenant_id;
ALTER TABLE deployments DROP COLUMN IF EXISTS tenant_id;
ALTER TABLE gateway_routes DROP COLUMN IF EXISTS tenant_id;
ALTER TABLE cron_jobs DROP COLUMN IF EXISTS tenant_id;
ALTER TABLE subscriptions DROP COLUMN IF EXISTS tenant_id;
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

This down migration drops tenant_id columns from many tables without ALTER TABLE IF EXISTS .... The corresponding up migration guards with table-existence checks, so rollback can fail in environments where some optional tables don’t exist. Consider mirroring the up migration’s existence checks (or using ALTER TABLE IF EXISTS ... DROP COLUMN IF EXISTS tenant_id consistently) for all tables here.

Copilot uses AI. Check for mistakes.
Comment on lines 25 to 31
func (r *VpcRepository) Create(ctx context.Context, vpc *domain.VPC) error {
query := `
INSERT INTO vpcs (id, user_id, name, cidr_block, network_id, vxlan_id, status, arn, created_at)
VALUES ($1, $2, $3, NULLIF($4, '')::cidr, $5, $6, $7, $8, $9)
INSERT INTO vpcs (id, user_id, tenant_id, name, cidr_block, network_id, vxlan_id, status, arn, created_at)
VALUES ($1, $2, $3, $4, NULLIF($5, '')::cidr, $6, $7, $8, $9, $10)
`
_, err := r.db.Exec(ctx, query, vpc.ID, vpc.UserID, vpc.Name, vpc.CIDRBlock, vpc.NetworkID, vpc.VXLANID, vpc.Status, vpc.ARN, vpc.CreatedAt)
_, err := r.db.Exec(ctx, query, vpc.ID, vpc.UserID, vpc.TenantID, vpc.Name, vpc.CIDRBlock, vpc.NetworkID, vpc.VXLANID, vpc.Status, vpc.ARN, vpc.CreatedAt)
if err != nil {
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

VpcRepository.Create takes tenant_id from vpc.TenantID, but all read/list/delete methods scope by TenantIDFromContext(ctx). Current services (e.g. VpcService.CreateVPC) don’t populate vpc.TenantID, so this will insert uuid.Nil and likely fail FK checks or associate the VPC with the wrong tenant. Prefer deriving tenant_id from context (and returning an Unauthorized/InvalidInput error if missing) to keep scoping consistent and prevent cross-tenant writes.

Copilot uses AI. Check for mistakes.
Comment on lines 24 to 31
func (r *SecurityGroupRepository) Create(ctx context.Context, sg *domain.SecurityGroup) error {
query := `
INSERT INTO security_groups (id, user_id, vpc_id, name, description, arn, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO security_groups (id, user_id, tenant_id, vpc_id, name, description, arn, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err := r.db.Exec(ctx, query, sg.ID, sg.UserID, sg.VPCID, sg.Name, sg.Description, sg.ARN, sg.CreatedAt)
_, err := r.db.Exec(ctx, query, sg.ID, sg.UserID, sg.TenantID, sg.VPCID, sg.Name, sg.Description, sg.ARN, sg.CreatedAt)
if err != nil {
return errors.Wrap(errors.Internal, "failed to create security group", err)
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

SecurityGroupRepository.Create uses sg.TenantID for tenant_id, but other methods scope by TenantIDFromContext(ctx). SecurityGroupService.CreateGroup doesn’t set TenantID, so creates will likely fail or write incorrect data. Consider deriving tenant_id from context (and erroring if missing) to keep tenant scoping consistent across CRUD operations.

Copilot uses AI. Check for mistakes.
// @Summary Invite member to tenant
// @Tags Tenant
// @Security APIKeyAuth
// @Router /tenants/:id/members [post]
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

The Swagger @Router annotation uses Gin-style :id instead of OpenAPI path params ({id}), which results in invalid/incorrect paths in generated Swagger docs. Update this to /tenants/{id}/members [post] so clients and tooling resolve parameters correctly.

Suggested change
// @Router /tenants/:id/members [post]
// @Router /tenants/{id}/members [post]

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants