feat: implement acl for workspaces (#19094)

This commit is contained in:
ケイラ
2025-07-30 17:02:51 -06:00
committed by GitHub
parent d736af1fa3
commit eeb0bbefb9
17 changed files with 346 additions and 141 deletions
-104
View File
@@ -1,104 +0,0 @@
package regosql
import (
"fmt"
"golang.org/x/xerrors"
"github.com/open-policy-agent/opa/ast"
"github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes"
)
var (
_ sqltypes.VariableMatcher = ACLGroupVar{}
_ sqltypes.Node = ACLGroupVar{}
)
// ACLGroupVar is a variable matcher that handles group_acl and user_acl.
// The sql type is a jsonb object with the following structure:
//
// "group_acl": {
// "<group_name>": ["<actions>"]
// }
//
// This is a custom variable matcher as json objects have arbitrary complexity.
type ACLGroupVar struct {
StructSQL string
// input.object.group_acl -> ["input", "object", "group_acl"]
StructPath []string
// FieldReference handles referencing the subfields, which could be
// more variables. We pass one in as the global one might not be correctly
// scoped.
FieldReference sqltypes.VariableMatcher
// Instance fields
Source sqltypes.RegoSource
GroupNode sqltypes.Node
}
func ACLGroupMatcher(fieldReference sqltypes.VariableMatcher, structSQL string, structPath []string) ACLGroupVar {
return ACLGroupVar{StructSQL: structSQL, StructPath: structPath, FieldReference: fieldReference}
}
func (ACLGroupVar) UseAs() sqltypes.Node { return ACLGroupVar{} }
func (g ACLGroupVar) ConvertVariable(rego ast.Ref) (sqltypes.Node, bool) {
// "left" will be a map of group names to actions in rego.
// {
// "all_users": ["read"]
// }
left, err := sqltypes.RegoVarPath(g.StructPath, rego)
if err != nil {
return nil, false
}
aclGrp := ACLGroupVar{
StructSQL: g.StructSQL,
StructPath: g.StructPath,
FieldReference: g.FieldReference,
Source: sqltypes.RegoSource(rego.String()),
}
// We expect 1 more term. Either a ref or a string.
if len(left) != 1 {
return nil, false
}
// If the remaining is a variable, then we need to convert it.
// Assuming we support variable fields.
ref, ok := left[0].Value.(ast.Ref)
if ok && g.FieldReference != nil {
groupNode, ok := g.FieldReference.ConvertVariable(ref)
if ok {
aclGrp.GroupNode = groupNode
return aclGrp, true
}
}
// If it is a string, we assume it is a literal
groupName, ok := left[0].Value.(ast.String)
if ok {
aclGrp.GroupNode = sqltypes.String(string(groupName))
return aclGrp, true
}
// If we have not matched it yet, then it is something we do not recognize.
return nil, false
}
func (g ACLGroupVar) SQLString(cfg *sqltypes.SQLGenerator) string {
return fmt.Sprintf("%s->%s", g.StructSQL, g.GroupNode.SQLString(cfg))
}
func (g ACLGroupVar) ContainsSQL(cfg *sqltypes.SQLGenerator, other sqltypes.Node) (string, error) {
switch other.UseAs().(type) {
// Only supports containing other strings.
case sqltypes.AstString:
return fmt.Sprintf("%s ? %s", g.SQLString(cfg), other.SQLString(cfg)), nil
default:
return "", xerrors.Errorf("unsupported acl group contains %T", other)
}
}
+126
View File
@@ -0,0 +1,126 @@
package regosql
import (
"fmt"
"golang.org/x/xerrors"
"github.com/open-policy-agent/opa/ast"
"github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes"
)
var (
_ sqltypes.VariableMatcher = ACLMappingVar{}
_ sqltypes.Node = ACLMappingVar{}
)
// ACLMappingVar is a variable matcher that handles group_acl and user_acl.
// The sql type is a jsonb object with the following structure:
//
// "group_acl": {
// "<group_name>": ["<actions>"]
// }
//
// This is a custom variable matcher as json objects have arbitrary complexity.
type ACLMappingVar struct {
// SelectSQL is used to `SELECT` the ACL mapping from the table for the
// given resource. ie. if the full query might look like `SELECT group_acl
// FROM things;` then you would want this to be `"group_acl"`.
SelectSQL string
// IndexMatcher handles variable references when indexing into the mapping.
// (ie. `input.object.acl_group_list[input.object.org_owner]`). We need one
// from the local context because the global one might not be correctly
// scoped.
IndexMatcher sqltypes.VariableMatcher
// Used if the action list isn't directly in the ACL entry. For example, in
// the `workspaces.group_acl` and `workspaces.user_acl` columns they're stored
// under a `"permissions"` key.
Subfield string
// StructPath represents the path of the value in rego
// ie. input.object.group_acl -> ["input", "object", "group_acl"]
StructPath []string
// Instance fields
Source sqltypes.RegoSource
GroupNode sqltypes.Node
}
func ACLMappingMatcher(indexMatcher sqltypes.VariableMatcher, selectSQL string, structPath []string) ACLMappingVar {
return ACLMappingVar{IndexMatcher: indexMatcher, SelectSQL: selectSQL, StructPath: structPath}
}
func (g ACLMappingVar) UsingSubfield(subfield string) ACLMappingVar {
g.Subfield = subfield
return g
}
func (ACLMappingVar) UseAs() sqltypes.Node { return ACLMappingVar{} }
func (g ACLMappingVar) ConvertVariable(rego ast.Ref) (sqltypes.Node, bool) {
// "left" will be a map of group names to actions in rego.
// {
// "all_users": ["read"]
// }
left, err := sqltypes.RegoVarPath(g.StructPath, rego)
if err != nil {
return nil, false
}
aclGrp := ACLMappingVar{
SelectSQL: g.SelectSQL,
IndexMatcher: g.IndexMatcher,
Subfield: g.Subfield,
StructPath: g.StructPath,
Source: sqltypes.RegoSource(rego.String()),
}
// We expect 1 more term. Either a ref or a string.
if len(left) != 1 {
return nil, false
}
// If the remaining is a variable, then we need to convert it.
// Assuming we support variable fields.
ref, ok := left[0].Value.(ast.Ref)
if ok && g.IndexMatcher != nil {
groupNode, ok := g.IndexMatcher.ConvertVariable(ref)
if ok {
aclGrp.GroupNode = groupNode
return aclGrp, true
}
}
// If it is a string, we assume it is a literal
groupName, ok := left[0].Value.(ast.String)
if ok {
aclGrp.GroupNode = sqltypes.String(string(groupName))
return aclGrp, true
}
// If we have not matched it yet, then it is something we do not recognize.
return nil, false
}
func (g ACLMappingVar) SQLString(cfg *sqltypes.SQLGenerator) string {
if g.Subfield != "" {
// We can't use subsequent -> operators because the first one might return
// NULL, which would result in an error like "column does not exist"' from
// the second.
return fmt.Sprintf("%s#>array[%s, '%s']", g.SelectSQL, g.GroupNode.SQLString(cfg), g.Subfield)
}
return fmt.Sprintf("%s->%s", g.SelectSQL, g.GroupNode.SQLString(cfg))
}
func (g ACLMappingVar) ContainsSQL(cfg *sqltypes.SQLGenerator, other sqltypes.Node) (string, error) {
switch other.UseAs().(type) {
// Only supports containing other strings.
case sqltypes.AstString:
return fmt.Sprintf("%s ? %s", g.SQLString(cfg), other.SQLString(cfg)), nil
default:
return "", xerrors.Errorf("unsupported acl group contains %T", other)
}
}
+22 -2
View File
@@ -193,10 +193,30 @@ func TestRegoQueries(t *testing.T) {
`"read" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
`"*" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
},
ExpectedSQL: "((user_acl->'d5389ccc-57a4-4b13-8c3f-31747bcdc9f1' ? 'read') OR " +
"(user_acl->'d5389ccc-57a4-4b13-8c3f-31747bcdc9f1' ? '*'))",
ExpectedSQL: "((user_acl->'d5389ccc-57a4-4b13-8c3f-31747bcdc9f1' ? 'read')" +
" OR (user_acl->'d5389ccc-57a4-4b13-8c3f-31747bcdc9f1' ? '*'))",
VariableConverter: regosql.DefaultVariableConverter(),
},
{
Name: "UserWorkspaceACLAllow",
Queries: []string{
`"read" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
`"*" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
},
ExpectedSQL: "((workspaces.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? 'read')" +
" OR (workspaces.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? '*'))",
VariableConverter: regosql.WorkspaceConverter(),
},
{
Name: "GroupWorkspaceACLAllow",
Queries: []string{
`"read" in input.object.acl_group_list["96c55a0e-73b4-44fc-abac-70d53c35c04c"]`,
`"*" in input.object.acl_group_list["96c55a0e-73b4-44fc-abac-70d53c35c04c"]`,
},
ExpectedSQL: "((workspaces.group_acl#>array['96c55a0e-73b4-44fc-abac-70d53c35c04c', 'permissions'] ? 'read')" +
" OR (workspaces.group_acl#>array['96c55a0e-73b4-44fc-abac-70d53c35c04c', 'permissions'] ? '*'))",
VariableConverter: regosql.WorkspaceConverter(),
},
{
Name: "NoACLConfig",
Queries: []string{
+18 -18
View File
@@ -14,12 +14,12 @@ func userOwnerMatcher() sqltypes.VariableMatcher {
return sqltypes.StringVarMatcher("owner_id :: text", []string{"input", "object", "owner"})
}
func groupACLMatcher(m sqltypes.VariableMatcher) sqltypes.VariableMatcher {
return ACLGroupMatcher(m, "group_acl", []string{"input", "object", "acl_group_list"})
func groupACLMatcher(m sqltypes.VariableMatcher) ACLMappingVar {
return ACLMappingMatcher(m, "group_acl", []string{"input", "object", "acl_group_list"})
}
func userACLMatcher(m sqltypes.VariableMatcher) sqltypes.VariableMatcher {
return ACLGroupMatcher(m, "user_acl", []string{"input", "object", "acl_user_list"})
func userACLMatcher(m sqltypes.VariableMatcher) ACLMappingVar {
return ACLMappingMatcher(m, "user_acl", []string{"input", "object", "acl_user_list"})
}
func TemplateConverter() *sqltypes.VariableConverter {
@@ -36,6 +36,20 @@ func TemplateConverter() *sqltypes.VariableConverter {
return matcher
}
func WorkspaceConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.StringVarMatcher("workspaces.organization_id :: text", []string{"input", "object", "org_owner"}),
userOwnerMatcher(),
)
matcher.RegisterMatcher(
ACLMappingMatcher(matcher, "workspaces.group_acl", []string{"input", "object", "acl_group_list"}).UsingSubfield("permissions"),
ACLMappingMatcher(matcher, "workspaces.user_acl", []string{"input", "object", "acl_user_list"}).UsingSubfield("permissions"),
)
return matcher
}
func AuditLogConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
@@ -81,20 +95,6 @@ func UserConverter() *sqltypes.VariableConverter {
return matcher
}
func WorkspaceConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.StringVarMatcher("workspaces.organization_id :: text", []string{"input", "object", "org_owner"}),
userOwnerMatcher(),
)
matcher.RegisterMatcher(
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
)
return matcher
}
// NoACLConverter should be used when the target SQL table does not contain
// group or user ACL columns.
func NoACLConverter() *sqltypes.VariableConverter {