mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add boundary_log rbac resource (#24810)
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>
This commit is contained in:
@@ -89,6 +89,15 @@ var (
|
||||
Type: "audit_log",
|
||||
}
|
||||
|
||||
// ResourceBoundaryLog
|
||||
// Valid Actions
|
||||
// - "ActionCreate" :: create boundary log records
|
||||
// - "ActionDelete" :: delete boundary logs
|
||||
// - "ActionRead" :: read boundary logs and session metadata
|
||||
ResourceBoundaryLog = Object{
|
||||
Type: "boundary_log",
|
||||
}
|
||||
|
||||
// ResourceBoundaryUsage
|
||||
// Valid Actions
|
||||
// - "ActionDelete" :: delete boundary usage statistics
|
||||
@@ -478,6 +487,7 @@ func AllResources() []Objecter {
|
||||
ResourceAssignOrgRole,
|
||||
ResourceAssignRole,
|
||||
ResourceAuditLog,
|
||||
ResourceBoundaryLog,
|
||||
ResourceBoundaryUsage,
|
||||
ResourceChat,
|
||||
ResourceConnectionLog,
|
||||
|
||||
@@ -422,6 +422,13 @@ var RBACPermissions = map[string]PermissionDefinition{
|
||||
ActionRead: "read AI seat state",
|
||||
},
|
||||
},
|
||||
"boundary_log": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionCreate: "create boundary log records",
|
||||
ActionRead: "read boundary logs and session metadata",
|
||||
ActionDelete: "delete boundary logs",
|
||||
},
|
||||
},
|
||||
"boundary_usage": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionRead: "read boundary usage statistics",
|
||||
|
||||
+15
-3
@@ -303,7 +303,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec.
|
||||
// Owners can inspect and delete personal skills for operability and
|
||||
// abuse handling, but cannot create or edit user-authored instructions.
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceAiSeat),
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat),
|
||||
// This adds back in the Workspace permissions.
|
||||
Permissions(map[string][]policy.Action{
|
||||
ResourceWorkspace.Type: ownerWorkspaceActions,
|
||||
@@ -313,6 +313,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// Explicitly setting PrebuiltWorkspace permissions for clarity.
|
||||
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
|
||||
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
|
||||
// Owners can read all boundary logs. Delete is reserved for
|
||||
// DBPurge only. Create is user-scoped (inherited from member).
|
||||
ResourceBoundaryLog.Type: {policy.ActionRead},
|
||||
})...,
|
||||
),
|
||||
User: []Permission{},
|
||||
@@ -332,7 +335,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
denyPermissions...,
|
||||
),
|
||||
User: append(
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat),
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat),
|
||||
Permissions(map[string][]policy.Action{
|
||||
// Users cannot do create/update/delete on themselves, but they
|
||||
// can read their own details.
|
||||
@@ -342,6 +345,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// Members can create and update AI Bridge interceptions but
|
||||
// cannot read them back.
|
||||
ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate},
|
||||
// Workspace agents create boundary logs under their owner's
|
||||
// identity. Create is user-scoped so agents can only write
|
||||
// logs owned by their workspace owner.
|
||||
// Read: owners and auditors. Delete: DBPurge only.
|
||||
ResourceBoundaryLog.Type: {policy.ActionCreate},
|
||||
})...,
|
||||
),
|
||||
ByOrgID: map[string]OrgPermissions{},
|
||||
@@ -366,6 +374,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
ResourceDeploymentConfig.Type: {policy.ActionRead},
|
||||
// Allow auditors to query AI Bridge interceptions.
|
||||
ResourceAibridgeInterception.Type: {policy.ActionRead},
|
||||
// Allow auditors to read boundary logs.
|
||||
ResourceBoundaryLog.Type: {policy.ActionRead},
|
||||
}),
|
||||
User: []Permission{},
|
||||
ByOrgID: map[string]OrgPermissions{},
|
||||
@@ -465,7 +475,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// Org admins should not have workspace exec perms.
|
||||
organizationID.String(): {
|
||||
Org: append(
|
||||
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceAiSeat),
|
||||
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat),
|
||||
Permissions(map[string][]policy.Action{
|
||||
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
|
||||
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
|
||||
@@ -1052,6 +1062,7 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
|
||||
ResourcePrebuiltWorkspace,
|
||||
ResourceUser,
|
||||
ResourceOrganizationMember,
|
||||
ResourceBoundaryLog,
|
||||
ResourceAibridgeInterception,
|
||||
// Chat access requires the agents-access role.
|
||||
ResourceChat,
|
||||
@@ -1137,6 +1148,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
|
||||
ResourcePrebuiltWorkspace,
|
||||
ResourceUser,
|
||||
ResourceOrganizationMember,
|
||||
ResourceBoundaryLog,
|
||||
ResourceAibridgeInterception,
|
||||
// Chat access requires the agents-access role.
|
||||
ResourceChat,
|
||||
|
||||
@@ -1229,6 +1229,75 @@ func TestRolePermissions(t *testing.T) {
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Boundary logs: members can create logs they own (user-scoped).
|
||||
// memberMe and agentsAccessUser have ID == currentUser, so they
|
||||
// match the resource owner. Other subjects have different IDs.
|
||||
Name: "BoundaryLogCreate",
|
||||
Actions: []policy.Action{policy.ActionCreate},
|
||||
Resource: rbac.ResourceBoundaryLog.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {memberMe, agentsAccessUser},
|
||||
false: {
|
||||
owner,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor, auditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
userAdmin, orgUserAdmin, otherOrgUserAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Cross-user isolation: no subject can create boundary logs
|
||||
// owned by a different user. The resource owner is a random
|
||||
// UUID that does not match any test subject's ID.
|
||||
Name: "BoundaryLogCreateOther",
|
||||
Actions: []policy.Action{policy.ActionCreate},
|
||||
Resource: rbac.ResourceBoundaryLog.WithOwner(uuid.New().String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {},
|
||||
false: {
|
||||
owner, memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor, auditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
userAdmin, orgUserAdmin, otherOrgUserAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Boundary logs: only DBPurge can delete. No human role
|
||||
// has delete; DBPurge is a system subject outside this matrix.
|
||||
Name: "BoundaryLogDelete",
|
||||
Actions: []policy.Action{policy.ActionDelete},
|
||||
Resource: rbac.ResourceBoundaryLog,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {},
|
||||
false: {
|
||||
owner, memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor, auditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
userAdmin, orgUserAdmin, otherOrgUserAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Boundary logs: owner and auditor get read.
|
||||
Name: "BoundaryLogRead",
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceBoundaryLog,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, auditor},
|
||||
false: {
|
||||
memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
userAdmin, orgUserAdmin, otherOrgUserAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ChatUsageCRU",
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
|
||||
@@ -1471,3 +1540,121 @@ func TestChangeSet(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceAgentScopeBoundaryLog verifies that a real workspace agent
|
||||
// scope (not ScopeAll) can create boundary logs for its own owner but
|
||||
// cannot create them for other users, and cannot read or delete them.
|
||||
func TestWorkspaceAgentScopeBoundaryLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry())
|
||||
|
||||
ownerID := uuid.New()
|
||||
otherOwnerID := uuid.New()
|
||||
workspaceID := uuid.New()
|
||||
templateID := uuid.New()
|
||||
versionID := uuid.New()
|
||||
|
||||
agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: templateID,
|
||||
VersionID: versionID,
|
||||
})
|
||||
|
||||
memberRole, err := rbac.RoleByName(rbac.RoleMember())
|
||||
require.NoError(t, err)
|
||||
|
||||
agent := rbac.Subject{
|
||||
ID: ownerID.String(),
|
||||
Roles: rbac.Roles{memberRole},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
// Agent can create boundary logs for its own owner.
|
||||
err = auth.Authorize(context.Background(), agent, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()))
|
||||
require.NoError(t, err, "agent should create boundary logs for own owner")
|
||||
|
||||
// Agent cannot create boundary logs for a different owner.
|
||||
err = auth.Authorize(context.Background(), agent, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String()))
|
||||
require.Error(t, err, "agent must not create boundary logs for other owner")
|
||||
|
||||
// Agent cannot read boundary logs (even its own owner's).
|
||||
err = auth.Authorize(context.Background(), agent, policy.ActionRead,
|
||||
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()))
|
||||
require.Error(t, err, "agent must not read boundary logs")
|
||||
|
||||
// Agent cannot delete boundary logs (even its own owner's).
|
||||
err = auth.Authorize(context.Background(), agent, policy.ActionDelete,
|
||||
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()))
|
||||
require.Error(t, err, "agent must not delete boundary logs")
|
||||
|
||||
// When the workspace owner is a site admin, the agent scope
|
||||
// wildcard for boundary_log combined with the owner role's site-level
|
||||
// read grant means the agent CAN read all boundary logs. This is an
|
||||
// accepted consequence of the wildcard scope needed for creation.
|
||||
ownerRole, err := rbac.RoleByName(rbac.RoleOwner())
|
||||
require.NoError(t, err)
|
||||
|
||||
adminAgent := rbac.Subject{
|
||||
ID: ownerID.String(),
|
||||
Roles: rbac.Roles{memberRole, ownerRole},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
// Admin-owned agent CAN read boundary logs due to site-level owner
|
||||
// role + wildcard scope.
|
||||
err = auth.Authorize(context.Background(), adminAgent, policy.ActionRead,
|
||||
rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String()))
|
||||
require.NoError(t, err, "admin agent inherits site-level read via owner role")
|
||||
|
||||
// Admin-owned agent still cannot create boundary logs for another owner
|
||||
// because member-level create is user-scoped (subject.id must match owner).
|
||||
err = auth.Authorize(context.Background(), adminAgent, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String()))
|
||||
require.Error(t, err, "admin agent must not create boundary logs for other owner")
|
||||
}
|
||||
|
||||
// TestDBPurgeBoundaryLogDelete verifies that the DBPurge system subject
|
||||
// can delete boundary logs but cannot create or read them.
|
||||
func TestDBPurgeBoundaryLogDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry())
|
||||
|
||||
// Build the DBPurge subject the same way dbauthz does.
|
||||
dbPurge := rbac.Subject{
|
||||
Type: rbac.SubjectTypeDBPurge,
|
||||
FriendlyName: "DB Purge",
|
||||
ID: uuid.Nil.String(),
|
||||
Roles: rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleIdentifier{Name: "dbpurge"},
|
||||
DisplayName: "DB Purge Daemon",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceBoundaryLog.Type: {policy.ActionDelete},
|
||||
}),
|
||||
User: []rbac.Permission{},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
},
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
// DBPurge can delete boundary logs.
|
||||
err := auth.Authorize(context.Background(), dbPurge, policy.ActionDelete,
|
||||
rbac.ResourceBoundaryLog)
|
||||
require.NoError(t, err, "DBPurge should delete boundary logs")
|
||||
|
||||
// DBPurge cannot create boundary logs.
|
||||
err = auth.Authorize(context.Background(), dbPurge, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(uuid.New().String()))
|
||||
require.Error(t, err, "DBPurge must not create boundary logs")
|
||||
|
||||
// DBPurge cannot read boundary logs.
|
||||
err = auth.Authorize(context.Background(), dbPurge, policy.ActionRead,
|
||||
rbac.ResourceBoundaryLog)
|
||||
require.Error(t, err, "DBPurge must not read boundary logs")
|
||||
}
|
||||
|
||||
@@ -65,6 +65,11 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope {
|
||||
{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...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ const (
|
||||
ScopeAssignRoleUnassign ScopeName = "assign_role:unassign"
|
||||
ScopeAuditLogCreate ScopeName = "audit_log:create"
|
||||
ScopeAuditLogRead ScopeName = "audit_log:read"
|
||||
ScopeBoundaryLogCreate ScopeName = "boundary_log:create"
|
||||
ScopeBoundaryLogDelete ScopeName = "boundary_log:delete"
|
||||
ScopeBoundaryLogRead ScopeName = "boundary_log:read"
|
||||
ScopeBoundaryUsageDelete ScopeName = "boundary_usage:delete"
|
||||
ScopeBoundaryUsageRead ScopeName = "boundary_usage:read"
|
||||
ScopeBoundaryUsageUpdate ScopeName = "boundary_usage:update"
|
||||
@@ -210,6 +213,9 @@ func (e ScopeName) Valid() bool {
|
||||
ScopeAssignRoleUnassign,
|
||||
ScopeAuditLogCreate,
|
||||
ScopeAuditLogRead,
|
||||
ScopeBoundaryLogCreate,
|
||||
ScopeBoundaryLogDelete,
|
||||
ScopeBoundaryLogRead,
|
||||
ScopeBoundaryUsageDelete,
|
||||
ScopeBoundaryUsageRead,
|
||||
ScopeBoundaryUsageUpdate,
|
||||
@@ -388,6 +394,9 @@ func AllScopeNameValues() []ScopeName {
|
||||
ScopeAssignRoleUnassign,
|
||||
ScopeAuditLogCreate,
|
||||
ScopeAuditLogRead,
|
||||
ScopeBoundaryLogCreate,
|
||||
ScopeBoundaryLogDelete,
|
||||
ScopeBoundaryLogRead,
|
||||
ScopeBoundaryUsageDelete,
|
||||
ScopeBoundaryUsageRead,
|
||||
ScopeBoundaryUsageUpdate,
|
||||
|
||||
Reference in New Issue
Block a user