mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
## Description
This PR adds support for deleting prebuilt workspaces via the
authorization layer. It introduces special-case handling to ensure that
`prebuilt_workspace` permissions are evaluated when attempting to delete
a prebuilt workspace, falling back to the standard `workspace` resource
as needed.
Prebuilt workspaces are a subset of workspaces, identified by having
`owner_id` set to `PREBUILD_SYSTEM_USER`.
This means:
* A user with `prebuilt_workspace.delete` permission is allowed to
**delete only prebuilt workspaces**.
* A user with `workspace.delete` permission can **delete both normal and
prebuilt workspaces**.
⚠️ This implementation is scoped to **deletion operations only**. No
other operations are currently supported for the `prebuilt_workspace`
resource.
To delete a workspace, users must have the following permissions:
* `workspace.read`: to read the current workspace state
* `update`: to modify workspace metadata and related resources during
deletion (e.g., updating the `deleted` field in the database)
* `delete`: to perform the actual deletion of the workspace
## Changes
* Introduced `authorizeWorkspace()` helper to handle prebuilt workspace
authorization logic.
* Ensured both `prebuilt_workspace` and `workspace` permissions are
checked.
* Added comments to clarify the current behavior and limitations.
* Moved `SystemUserID` constant from the `prebuilds` package to the
`database` package `PrebuildsSystemUserID` to resolve an import cycle
(commit
f24e4ab4b6
).
* Update middleware `ExtractOrganizationMember` to include system user
members.
265 lines
9.5 KiB
Go
265 lines
9.5 KiB
Go
package httpmw
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
type (
|
|
organizationParamContextKey struct{}
|
|
organizationMemberParamContextKey struct{}
|
|
organizationMembersParamContextKey struct{}
|
|
)
|
|
|
|
// OrganizationParam returns the organization from the ExtractOrganizationParam handler.
|
|
func OrganizationParam(r *http.Request) database.Organization {
|
|
organization, ok := r.Context().Value(organizationParamContextKey{}).(database.Organization)
|
|
if !ok {
|
|
panic("developer error: organization param middleware not provided")
|
|
}
|
|
return organization
|
|
}
|
|
|
|
// OrganizationMemberParam returns the organization membership that allowed the query
|
|
// from the ExtractOrganizationParam handler.
|
|
func OrganizationMemberParam(r *http.Request) OrganizationMember {
|
|
organizationMember, ok := r.Context().Value(organizationMemberParamContextKey{}).(OrganizationMember)
|
|
if !ok {
|
|
panic("developer error: organization member param middleware not provided")
|
|
}
|
|
return organizationMember
|
|
}
|
|
|
|
func OrganizationMembersParam(r *http.Request) OrganizationMembers {
|
|
organizationMembers, ok := r.Context().Value(organizationMembersParamContextKey{}).(OrganizationMembers)
|
|
if !ok {
|
|
panic("developer error: organization members param middleware not provided")
|
|
}
|
|
return organizationMembers
|
|
}
|
|
|
|
// ExtractOrganizationParam grabs an organization from the "organization" URL parameter.
|
|
// This middleware requires the API key middleware higher in the call stack for authentication.
|
|
func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
arg := chi.URLParam(r, "organization")
|
|
if arg == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "\"organization\" must be provided.",
|
|
})
|
|
return
|
|
}
|
|
|
|
var organization database.Organization
|
|
var dbErr error
|
|
|
|
// If the name is exactly "default", then we fetch the default
|
|
// organization. This is a special case to make it easier
|
|
// for single org deployments.
|
|
//
|
|
// arg == uuid.Nil.String() should be a temporary workaround for
|
|
// legacy provisioners that don't provide an organization ID.
|
|
// This prevents a breaking change.
|
|
// TODO: This change was added March 2024. Nil uuid returning the
|
|
// default org should be removed some number of months after
|
|
// that date.
|
|
if arg == codersdk.DefaultOrganization || arg == uuid.Nil.String() {
|
|
organization, dbErr = db.GetDefaultOrganization(ctx)
|
|
} else {
|
|
// Try by name or uuid.
|
|
id, err := uuid.Parse(arg)
|
|
if err == nil {
|
|
organization, dbErr = db.GetOrganizationByID(ctx, id)
|
|
} else {
|
|
organization, dbErr = db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
|
|
Name: arg,
|
|
Deleted: false,
|
|
})
|
|
}
|
|
}
|
|
if httpapi.Is404Error(dbErr) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if dbErr != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: fmt.Sprintf("Internal error fetching organization %q.", arg),
|
|
Detail: dbErr.Error(),
|
|
})
|
|
return
|
|
}
|
|
ctx = context.WithValue(ctx, organizationParamContextKey{}, organization)
|
|
next.ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// OrganizationMember is the database object plus the Username and Avatar URL. Including these
|
|
// in the middleware is preferable to a join at the SQL layer so that we can keep the
|
|
// autogenerated database types as they are.
|
|
type OrganizationMember struct {
|
|
database.OrganizationMember
|
|
Username string
|
|
AvatarURL string
|
|
}
|
|
|
|
// ExtractOrganizationMemberParam grabs a user membership from the "organization" and "user" URL parameter.
|
|
// This middleware requires the ExtractUser and ExtractOrganization middleware higher in the stack
|
|
func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
organization := OrganizationParam(r)
|
|
_, members, done := ExtractOrganizationMember(ctx, nil, rw, r, db, organization.ID)
|
|
if done {
|
|
return
|
|
}
|
|
|
|
if len(members) != 1 {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching organization member.",
|
|
// This is a developer error and should never happen.
|
|
Detail: fmt.Sprintf("Expected exactly one organization member, but got %d.", len(members)),
|
|
})
|
|
return
|
|
}
|
|
|
|
organizationMember := members[0]
|
|
|
|
ctx = context.WithValue(ctx, organizationMemberParamContextKey{}, OrganizationMember{
|
|
OrganizationMember: organizationMember.OrganizationMember,
|
|
// Here we're making two exceptions to the rule about not leaking data about the user
|
|
// to the API handler, which is to include the username and avatar URL.
|
|
// If the caller has permission to read the OrganizationMember, then we're explicitly
|
|
// saying here that they also have permission to see the member's username and avatar.
|
|
// This is OK!
|
|
//
|
|
// API handlers need this information for audit logging and returning the owner's
|
|
// username in response to creating a workspace. Additionally, the frontend consumes
|
|
// the Avatar URL and this allows the FE to avoid an extra request.
|
|
Username: organizationMember.Username,
|
|
AvatarURL: organizationMember.AvatarURL,
|
|
})
|
|
|
|
next.ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// ExtractOrganizationMember extracts all user memberships from the "user" URL
|
|
// parameter. If orgID is uuid.Nil, then it will return all memberships for the
|
|
// user, otherwise it will only return memberships to the org.
|
|
//
|
|
// If `user` is returned, that means the caller can use the data. This is returned because
|
|
// it is possible to have a user with 0 organizations. So the user != nil, with 0 memberships.
|
|
func ExtractOrganizationMember(ctx context.Context, auth func(r *http.Request, action policy.Action, object rbac.Objecter) bool, rw http.ResponseWriter, r *http.Request, db database.Store, orgID uuid.UUID) (*database.User, []database.OrganizationMembersRow, bool) {
|
|
// We need to resolve the `{user}` URL parameter so that we can get the userID and
|
|
// username. We do this as SystemRestricted since the caller might have permission
|
|
// to access the OrganizationMember object, but *not* the User object. So, it is
|
|
// very important that we do not add the User object to the request context or otherwise
|
|
// leak it to the API handler.
|
|
// nolint:gocritic
|
|
user, ok := ExtractUserContext(dbauthz.AsSystemRestricted(ctx), db, rw, r)
|
|
if !ok {
|
|
return nil, nil, true
|
|
}
|
|
|
|
organizationMembers, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{
|
|
OrganizationID: orgID,
|
|
UserID: user.ID,
|
|
IncludeSystem: true,
|
|
})
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return nil, nil, true
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching organization member.",
|
|
Detail: err.Error(),
|
|
})
|
|
return nil, nil, true
|
|
}
|
|
|
|
// Only return the user data if the caller can read the user object.
|
|
if auth != nil && auth(r, policy.ActionRead, user) {
|
|
return &user, organizationMembers, false
|
|
}
|
|
|
|
// If the user cannot be read and 0 memberships exist, throw a 404 to not
|
|
// leak the user existence.
|
|
if len(organizationMembers) == 0 {
|
|
httpapi.ResourceNotFound(rw)
|
|
return nil, nil, true
|
|
}
|
|
|
|
return nil, organizationMembers, false
|
|
}
|
|
|
|
type OrganizationMembers struct {
|
|
// User is `nil` if the caller is not allowed access to the site wide
|
|
// user object.
|
|
User *database.User
|
|
// Memberships can only be length 0 if `user != nil`. If `user == nil`, then
|
|
// memberships will be at least length 1.
|
|
Memberships []OrganizationMember
|
|
}
|
|
|
|
func (om OrganizationMembers) UserID() uuid.UUID {
|
|
if om.User != nil {
|
|
return om.User.ID
|
|
}
|
|
|
|
if len(om.Memberships) > 0 {
|
|
return om.Memberships[0].UserID
|
|
}
|
|
return uuid.Nil
|
|
}
|
|
|
|
// ExtractOrganizationMembersParam grabs all user organization memberships.
|
|
// Only requires the "user" URL parameter.
|
|
//
|
|
// Use this if you want to grab as much information for a user as you can.
|
|
// From an organization context, site wide user information might not available.
|
|
func ExtractOrganizationMembersParam(db database.Store, auth func(r *http.Request, action policy.Action, object rbac.Objecter) bool) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Fetch all memberships
|
|
user, members, done := ExtractOrganizationMember(ctx, auth, rw, r, db, uuid.Nil)
|
|
if done {
|
|
return
|
|
}
|
|
|
|
orgMembers := make([]OrganizationMember, 0, len(members))
|
|
for _, organizationMember := range members {
|
|
orgMembers = append(orgMembers, OrganizationMember{
|
|
OrganizationMember: organizationMember.OrganizationMember,
|
|
Username: organizationMember.Username,
|
|
AvatarURL: organizationMember.AvatarURL,
|
|
})
|
|
}
|
|
|
|
ctx = context.WithValue(ctx, organizationMembersParamContextKey{}, OrganizationMembers{
|
|
User: user,
|
|
Memberships: orgMembers,
|
|
})
|
|
next.ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|