fix(server): check workspace membership for private project access (SEC-03) - #2359
Conversation
FindActiveById only checked whether an operator was present, not whether that operator actually belonged to the project's workspace. Any non-nil operator satisfied the check, including one built from an unauthenticated internal-API "user-id" header naming any existing user with no relationship to the project at all -- letting that caller read private projects of workspaces they aren't a member of. Check the operator's actual workspace membership instead of just its presence, matching the pattern already used elsewhere in this file for project access checks.
There was a problem hiding this comment.
Pull request overview
This PR closes an authorization gap in the server’s project interactor by ensuring that access to private projects requires not just a non-nil operator, but an operator that is actually readable for the project’s workspace. This directly hardens the internal gRPC GetProject path described in the PR metadata.
Changes:
- Tightened
FindActiveById’s private-project gate to requireoperator.IsReadableWorkspace(pj.Workspace()), not merelyoperator != nil. - Added a regression test that ensures a non-nil operator with no relationship to the workspace is denied access to a private project.
- Updated test imports to use
requirefor the new assertion.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| server/internal/usecase/interactor/project.go | Fixes private-project authorization by validating workspace readability for the operator. |
| server/internal/usecase/interactor/project_test.go | Adds a regression test covering the previously-missing workspace membership check for private projects. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
airslice
left a comment
There was a problem hiding this comment.
This correctly closes the cross-workspace authorization gap, but do we also have a separate issue tracking authentication for the internal gRPC read-only methods?
From the PR description, a caller can still provide an arbitrary existing user-id without proof of identity. If they know the ID of a user who is a member of the target workspace, wouldn't IsReadableWorkspace then succeed?
I agree that this authorization check belongs here regardless, but it seems the ability to construct an operator from an unverified user-id is a separate defense-in-depth issue worth tracking.
Agreed. Will make a follow-up for this in a separate notion task. |
Summary
FindActiveByIdonly checked whether an operator was present, not whether that operator actually belonged to the project's workspace. Any non-nil operator satisfied the check.This matters for the internal gRPC API's
GetProject: for read-only methods, the auth interceptor skips the bearer-token check entirely, and the operator-attaching interceptor builds a full operator from a bareuser-idmetadata header with no proof of identity. So a caller who can reach the internal gRPC port could name any existing user ID with no credentials and read a private project belonging to a workspace that user has no relationship to.Real-world exploitability is limited: the internal gRPC port is VPC-internal only in the current deployment, not reachable from the public internet. This closes the gap regardless, since it's a genuine authorization bug independent of network exposure, and the current network boundary is an infra detail, not a code guarantee.
Fix
Check the operator's actual workspace membership (
operator.IsReadableWorkspace(pj.Workspace())) instead of just its presence, matching the pattern already used elsewhere in this file (e.g.Fetch) for project access checks.Test plan
go build ./...passesinternalapi/server.go'sGetProjectis the only caller of this interactor method, so the fix is scoped correctly