mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
a586b7e5e0
RFC: [Bridge ↔ Boundaries Correlation RFC](https://www.notion.so/coderhq/Gateway-and-Firewall-Correlation-RFC-31ad579be592803aa8b3d48348ccdde9) Register a dedicated `boundary_log` RBAC resource type with `create`, `read`, and `delete` actions, replacing the placeholder `rbac.ResourceAuditLog` and `rbac.ResourceSystem` references previously used in the dbauthz layer. Create is granted at user-level so workspace agents can only write logs owned by their workspace owner, preventing cross-workspace log fabrication. Delete is restricted to `DBPurge` only; no human role (including owner) can delete boundary logs. | Subject | Create (own) | Create (other) | Read (all) | Delete | |---|---|---|---|---| | Workspace agent | yes | no | no | no | | Owner (site admin) | yes (via member) | no | yes | no | | Auditor | no | no | yes | no | | DBPurge | no | no | no | yes | ### Changes - **RBAC policy & resource definition**: add `boundary_log` to `policy.go` and generate `ResourceBoundaryLog` object, scope constants, and codersdk/TypeScript types. - **dbauthz authorization**: replace all `ResourceAuditLog`/`ResourceSystem` placeholders with `ResourceBoundaryLog`. `InsertBoundaryLog` and `InsertBoundarySession` derive the workspace owner from the agent and authorize with `.WithOwner()` for user-scoped create. - **Role assignments:** - **Owner (site):** read only. Excluded from `allPermsExcept` wildcard; create is inherited from member at user-level. - **Member (user-level):** create. User-scoped so agents can only write logs they own. - **Auditor (site):** read. - `boundary_log` is excluded from org-admin, org-member, and org-service-account `allPermsExcept` calls for consistency with `ResourceBoundaryUsage`. - **System subjects:** - **DB Purge** (`SubjectTypeDBPurge`): delete. The only subject that can remove boundary logs. - **Workspace agent scope**: `ResourceBoundaryLog` with wildcard ID in the agent scope allow-list (necessary for creation since no pre-existing ID exists). User-level role scoping prevents deployment-wide access. - **DB migration** (`000510_boundary_log_scopes`): add `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read` enum values to `api_key_scope`. - **Test coverage**: `BoundaryLogCreate` (user-scoped, only matching owner succeeds), `BoundaryLogDelete` (all human roles denied), `BoundaryLogRead` (owner + auditor). dbauthz mock tests set up workspace agent lookups for owner derivation. - **Generated docs**: update OpenAPI specs, API reference docs, and frontend type definitions. --------- Co-authored-by: Muhammad Danish <mdanishkhdev@gmail.com> Co-authored-by: Coder Agents <coder-agents-review[bot]@users.noreply.github.com>
321 lines
11 KiB
Go
321 lines
11 KiB
Go
package rbac
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
)
|
|
|
|
type WorkspaceAgentScopeParams struct {
|
|
WorkspaceID uuid.UUID
|
|
OwnerID uuid.UUID
|
|
TemplateID uuid.UUID
|
|
VersionID uuid.UUID
|
|
TaskID uuid.NullUUID
|
|
BlockUserData bool
|
|
}
|
|
|
|
// WorkspaceAgentScope returns a scope that is the same as ScopeAll but can only
|
|
// affect resources in the allow list. Only a scope is returned as the roles
|
|
// should come from the workspace owner.
|
|
func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope {
|
|
if params.WorkspaceID == uuid.Nil || params.OwnerID == uuid.Nil || params.TemplateID == uuid.Nil || params.VersionID == uuid.Nil {
|
|
panic("all uuids must be non-nil, this is a developer error")
|
|
}
|
|
|
|
var (
|
|
scope Scope
|
|
err error
|
|
)
|
|
if params.BlockUserData {
|
|
scope, err = ScopeNoUserData.Expand()
|
|
} else {
|
|
scope, err = ScopeAll.Expand()
|
|
}
|
|
if err != nil {
|
|
panic("failed to expand scope, this should never happen")
|
|
}
|
|
|
|
// Include task in the allow list if the workspace has an associated task.
|
|
var extraAllowList []AllowListElement
|
|
if params.TaskID.Valid {
|
|
extraAllowList = append(extraAllowList, AllowListElement{
|
|
Type: ResourceTask.Type,
|
|
ID: params.TaskID.UUID.String(),
|
|
})
|
|
}
|
|
|
|
return Scope{
|
|
// TODO: We want to limit the role too to be extra safe.
|
|
// Even though the allowlist blocks anything else, it is still good
|
|
// incase we change the behavior of the allowlist. The allowlist is new
|
|
// and evolving.
|
|
Role: scope.Role,
|
|
|
|
// Limit the agent to only be able to access the singular workspace and
|
|
// the template/version it was created from. Add additional resources here
|
|
// as needed, but do not add more workspace or template resource ids.
|
|
AllowIDList: append([]AllowListElement{
|
|
{Type: ResourceWorkspace.Type, ID: params.WorkspaceID.String()},
|
|
{Type: ResourceTemplate.Type, ID: params.TemplateID.String()},
|
|
{Type: ResourceTemplate.Type, ID: params.VersionID.String()},
|
|
{Type: ResourceUser.Type, ID: params.OwnerID.String()},
|
|
// No pre-existing ID for new records; wildcard is required.
|
|
// Owner-scoped create (user-level) limits agents to their own
|
|
// logs. Adding site-level actions to the member role would
|
|
// bypass this and grant deployment-wide access.
|
|
{Type: ResourceBoundaryLog.Type, ID: policy.WildcardSymbol},
|
|
}, extraAllowList...),
|
|
}
|
|
}
|
|
|
|
const (
|
|
ScopeAll ScopeName = "coder:all"
|
|
ScopeApplicationConnect ScopeName = "coder:application_connect"
|
|
ScopeNoUserData ScopeName = "no_user_data"
|
|
)
|
|
|
|
// TODO: Support passing in scopeID list for allowlisting resources.
|
|
var builtinScopes = map[ScopeName]Scope{
|
|
// ScopeAll is a special scope that allows access to all resources. During
|
|
// authorize checks it is usually not used directly and skips scope checks.
|
|
ScopeAll: {
|
|
Role: Role{
|
|
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", ScopeAll)},
|
|
DisplayName: "All operations",
|
|
Site: Permissions(map[string][]policy.Action{
|
|
ResourceWildcard.Type: {policy.WildcardSymbol},
|
|
}),
|
|
User: []Permission{},
|
|
ByOrgID: map[string]OrgPermissions{},
|
|
},
|
|
AllowIDList: []AllowListElement{AllowListAll()},
|
|
},
|
|
|
|
ScopeApplicationConnect: {
|
|
Role: Role{
|
|
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect)},
|
|
DisplayName: "Ability to connect to applications",
|
|
Site: Permissions(map[string][]policy.Action{
|
|
ResourceWorkspace.Type: {policy.ActionApplicationConnect},
|
|
}),
|
|
User: []Permission{},
|
|
ByOrgID: map[string]OrgPermissions{},
|
|
},
|
|
AllowIDList: []AllowListElement{AllowListAll()},
|
|
},
|
|
|
|
ScopeNoUserData: {
|
|
Role: Role{
|
|
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", ScopeNoUserData)},
|
|
DisplayName: "Scope without access to user data",
|
|
Site: allPermsExcept(ResourceUser),
|
|
User: []Permission{},
|
|
ByOrgID: map[string]OrgPermissions{},
|
|
},
|
|
AllowIDList: []AllowListElement{AllowListAll()},
|
|
},
|
|
}
|
|
|
|
// BuiltinScopeNames returns the list of built-in high-level scope names
|
|
// defined in this package (e.g., "all", "application_connect"). The result
|
|
// is sorted for deterministic ordering in code generation and tests.
|
|
func BuiltinScopeNames() []ScopeName {
|
|
names := make([]ScopeName, 0, len(builtinScopes))
|
|
for name := range builtinScopes {
|
|
names = append(names, name)
|
|
}
|
|
slices.Sort(names)
|
|
return names
|
|
}
|
|
|
|
// Composite coder:* scopes expand to multiple low-level resource:action permissions
|
|
// at Site level. These names are persisted in the DB and expanded during
|
|
// authorization.
|
|
var compositePerms = map[ScopeName]map[string][]policy.Action{
|
|
"coder:workspaces.create": {
|
|
ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse},
|
|
ResourceWorkspace.Type: {policy.ActionWorkspaceStop, policy.ActionWorkspaceStart, policy.ActionCreate, policy.ActionUpdate, policy.ActionRead},
|
|
// When creating a workspace, users need to be able to read the org member the
|
|
// workspace will be owned by. Even if that owner is "yourself".
|
|
ResourceOrganizationMember.Type: {policy.ActionRead},
|
|
},
|
|
"coder:workspaces.operate": {
|
|
ResourceTemplate.Type: {policy.ActionRead},
|
|
ResourceWorkspace.Type: {policy.ActionWorkspaceStop, policy.ActionWorkspaceStart, policy.ActionRead, policy.ActionUpdate},
|
|
ResourceOrganizationMember.Type: {policy.ActionRead},
|
|
},
|
|
"coder:workspaces.delete": {
|
|
ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse},
|
|
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionDelete},
|
|
ResourceOrganizationMember.Type: {policy.ActionRead},
|
|
},
|
|
"coder:workspaces.access": {
|
|
ResourceTemplate.Type: {policy.ActionRead},
|
|
ResourceOrganizationMember.Type: {policy.ActionRead},
|
|
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionSSH, policy.ActionApplicationConnect},
|
|
},
|
|
"coder:templates.build": {
|
|
ResourceTemplate.Type: {policy.ActionRead},
|
|
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
|
|
"provisioner_jobs": {policy.ActionRead},
|
|
},
|
|
"coder:templates.author": {
|
|
ResourceTemplate.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights},
|
|
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
|
|
},
|
|
"coder:apikeys.manage_self": {
|
|
ResourceApiKey.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
|
|
},
|
|
}
|
|
|
|
// CompositeSitePermissions returns the site-level Permission list for a coder:* scope.
|
|
func CompositeSitePermissions(name ScopeName) ([]Permission, bool) {
|
|
perms, ok := compositePerms[name]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return Permissions(perms), true
|
|
}
|
|
|
|
// CompositeScopeNames lists all high-level coder:* names in sorted order.
|
|
func CompositeScopeNames() []string {
|
|
out := make([]string, 0, len(compositePerms))
|
|
for k := range compositePerms {
|
|
out = append(out, string(k))
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
type ExpandableScope interface {
|
|
Expand() (Scope, error)
|
|
// Name is for logging and tracing purposes, we want to know the human
|
|
// name of the scope.
|
|
Name() RoleIdentifier
|
|
}
|
|
|
|
type ScopeName string
|
|
|
|
func (name ScopeName) Expand() (Scope, error) {
|
|
return ExpandScope(name)
|
|
}
|
|
|
|
func (name ScopeName) Name() RoleIdentifier {
|
|
return RoleIdentifier{Name: string(name)}
|
|
}
|
|
|
|
// Scope acts the exact same as a Role with the addition that is can also
|
|
// apply an AllowIDList. Any resource being checked against a Scope will
|
|
// reject any resource that is not in the AllowIDList.
|
|
// To not use an AllowIDList to reject authorization, use a wildcard for the
|
|
// AllowIDList. Eg: 'AllowIDList: []string{WildcardSymbol}'
|
|
type Scope struct {
|
|
Role
|
|
AllowIDList []AllowListElement `json:"allow_list"`
|
|
}
|
|
|
|
type AllowListElement struct {
|
|
// ID must be a string to allow for the wildcard symbol.
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
func AllowListAll() AllowListElement {
|
|
return AllowListElement{ID: policy.WildcardSymbol, Type: policy.WildcardSymbol}
|
|
}
|
|
|
|
// String encodes the allow list element into the canonical database representation
|
|
// "type:id". This avoids fragile manual concatenations scattered across the codebase.
|
|
func (e AllowListElement) String() string {
|
|
return e.Type + ":" + e.ID
|
|
}
|
|
|
|
func (s Scope) Expand() (Scope, error) {
|
|
return s, nil
|
|
}
|
|
|
|
func (s Scope) Name() RoleIdentifier {
|
|
return s.Identifier
|
|
}
|
|
|
|
func ExpandScope(scope ScopeName) (Scope, error) {
|
|
if role, ok := builtinScopes[scope]; ok {
|
|
return role, nil
|
|
}
|
|
if site, ok := CompositeSitePermissions(scope); ok {
|
|
return Scope{
|
|
Role: Role{
|
|
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", scope)},
|
|
DisplayName: string(scope),
|
|
Site: site,
|
|
User: []Permission{},
|
|
ByOrgID: map[string]OrgPermissions{},
|
|
},
|
|
// Composites are site-level; allow-list empty by default
|
|
AllowIDList: []AllowListElement{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}},
|
|
}, nil
|
|
}
|
|
if res, act, ok := parseLowLevelScope(scope); ok {
|
|
return expandLowLevel(res, act), nil
|
|
}
|
|
return Scope{}, xerrors.Errorf("no scope named %q", scope)
|
|
}
|
|
|
|
// ParseResourceAction parses a scope string formatted as "<resource>:<action>"
|
|
// and returns the resource and action components. This is the common parsing
|
|
// logic shared between RBAC and database validation.
|
|
func ParseResourceAction(scope string) (resource string, action string, ok bool) {
|
|
parts := strings.SplitN(scope, ":", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", "", false
|
|
}
|
|
return parts[0], parts[1], true
|
|
}
|
|
|
|
// parseLowLevelScope parses a low-level scope name formatted as
|
|
// "<resource>:<action>" and validates it against RBACPermissions.
|
|
// Returns the resource and action if valid.
|
|
func parseLowLevelScope(name ScopeName) (resource string, action policy.Action, ok bool) {
|
|
res, act, ok := ParseResourceAction(string(name))
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
|
|
def, exists := policy.RBACPermissions[res]
|
|
if !exists {
|
|
return "", "", false
|
|
}
|
|
|
|
if act == policy.WildcardSymbol {
|
|
return res, policy.WildcardSymbol, true
|
|
}
|
|
|
|
if _, exists := def.Actions[policy.Action(act)]; !exists {
|
|
return "", "", false
|
|
}
|
|
return res, policy.Action(act), true
|
|
}
|
|
|
|
// expandLowLevel constructs a site-only Scope with a single permission for the
|
|
// given resource and action. This mirrors how builtin scopes are represented
|
|
// but is restricted to site-level only.
|
|
func expandLowLevel(resource string, action policy.Action) Scope {
|
|
return Scope{
|
|
Role: Role{
|
|
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s:%s", resource, action)},
|
|
DisplayName: fmt.Sprintf("%s:%s", resource, action),
|
|
Site: []Permission{{ResourceType: resource, Action: action}},
|
|
User: []Permission{},
|
|
ByOrgID: map[string]OrgPermissions{},
|
|
},
|
|
// Low-level scopes intentionally return a wildcard allow list.
|
|
AllowIDList: []AllowListElement{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}},
|
|
}
|
|
}
|