mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: implement acl for workspaces (#19094)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user