Skip to content

Commit aaabbc6

Browse files
authored
feat(authz): implement read-only viewer role (#552)
Signed-off-by: Miguel Martinez Trivino <[email protected]>
1 parent f723f4c commit aaabbc6

39 files changed

+960
-261
lines changed

app/controlplane/cmd/wire_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/internal/authz/authz.go

Lines changed: 227 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,40 @@ import (
3333
"github.com/chainloop-dev/chainloop/app/controlplane/internal/conf"
3434
)
3535

36+
type Role string
37+
3638
const (
3739
// Actions
3840
ActionRead = "read"
3941
ActionList = "list"
42+
ActionCreate = "create"
4043
ActionUpdate = "update"
4144
ActionDelete = "delete"
4245

4346
// Resources
44-
ResourceWorkflowContract = "workflow_contract"
45-
ResourceCASArtifact = "cas_artifact"
46-
ResourceReferrer = "referrer"
47+
ResourceWorkflowContract = "workflow_contract"
48+
ResourceCASArtifact = "cas_artifact"
49+
ResourceCASBackend = "cas_backend"
50+
ResourceReferrer = "referrer"
51+
ResourceAvailableIntegration = "integration_available"
52+
ResourceRegisteredIntegration = "integration_registered"
53+
ResourceAttachedIntegration = "integration_attached"
54+
ResourceOrgMetric = "metrics_org"
55+
ResourceRobotAccount = "robot_account"
56+
ResourceWorkflowRun = "workflow_run"
57+
ResourceWorkflow = "workflow"
58+
UserMembership = "membership_user"
59+
Organization = "organization"
60+
61+
// We have for now three roles, viewer, admin and owner
62+
// The owner of an org
63+
// The administrator of an org
64+
// The read only viewer of an org
65+
// These roles are hierarchical
66+
// This means that the Owner role inherits all the policies from Admin so from the Viewer Role
67+
RoleOwner Role = "role:org:owner"
68+
RoleAdmin Role = "role:org:admin"
69+
RoleViewer Role = "role:org:viewer"
4770
)
4871

4972
// resource, action tuple
@@ -53,13 +76,119 @@ type Policy struct {
5376
}
5477

5578
var (
79+
// Referrer
80+
PolicyReferrerRead = &Policy{ResourceReferrer, ActionRead}
81+
// Artifact
82+
PolicyArtifactDownload = &Policy{ResourceCASArtifact, ActionRead}
83+
PolicyArtifactUpload = &Policy{ResourceCASArtifact, ActionCreate}
84+
// CAS backend
85+
PolicyCASBackendList = &Policy{ResourceCASBackend, ActionList}
86+
// Available integrations
87+
PolicyAvailableIntegrationList = &Policy{ResourceAvailableIntegration, ActionList}
88+
PolicyAvailableIntegrationRead = &Policy{ResourceAvailableIntegration, ActionRead}
89+
// Registered integrations
90+
PolicyRegisteredIntegrationList = &Policy{ResourceRegisteredIntegration, ActionList}
91+
// Attached integrations
92+
PolicyAttachedIntegrationList = &Policy{ResourceAttachedIntegration, ActionList}
93+
// Org Metrics
94+
PolicyOrgMetricsRead = &Policy{ResourceOrgMetric, ActionList}
95+
// Robot Account
96+
PolicyRobotAccountList = &Policy{ResourceRobotAccount, ActionList}
97+
// Workflow Contract
5698
PolicyWorkflowContractList = &Policy{ResourceWorkflowContract, ActionList}
5799
PolicyWorkflowContractRead = &Policy{ResourceWorkflowContract, ActionRead}
58100
PolicyWorkflowContractUpdate = &Policy{ResourceWorkflowContract, ActionUpdate}
59-
PolicyArtifactDownload = &Policy{ResourceCASArtifact, ActionRead}
60-
PolicyReferrerRead = &Policy{ResourceReferrer, ActionRead}
101+
// WorkflowRun
102+
PolicyWorkflowRunList = &Policy{ResourceWorkflowRun, ActionList}
103+
PolicyWorkflowRunRead = &Policy{ResourceWorkflowRun, ActionRead}
104+
// Workflow
105+
PolicyWorkflowList = &Policy{ResourceWorkflow, ActionList}
106+
// User Membership
107+
PolicyOrganizationRead = &Policy{Organization, ActionRead}
61108
)
62109

110+
// List of policies for each role
111+
// NOTE: roles are hierarchical, this means that the Admin Role can inherit all the policies from the Viewer Role
112+
// so we do not need to add them as well.
113+
var rolesMap = map[Role][]*Policy{
114+
RoleViewer: {
115+
// Referrer
116+
PolicyReferrerRead,
117+
// Artifact
118+
PolicyArtifactDownload,
119+
// CAS backend
120+
PolicyCASBackendList,
121+
// Available integrations
122+
PolicyAvailableIntegrationList,
123+
PolicyAvailableIntegrationRead,
124+
// Registered integrations
125+
PolicyRegisteredIntegrationList,
126+
// Attached integrations
127+
PolicyAttachedIntegrationList,
128+
// Metrics
129+
PolicyOrgMetricsRead,
130+
// Robot Account
131+
PolicyRobotAccountList,
132+
// Workflow Contract
133+
PolicyWorkflowContractList,
134+
PolicyWorkflowContractRead,
135+
// WorkflowRun
136+
PolicyWorkflowRunList,
137+
PolicyWorkflowRunRead,
138+
// Workflow
139+
PolicyWorkflowList,
140+
// Organization
141+
PolicyOrganizationRead,
142+
},
143+
RoleAdmin: {
144+
// We do a manual check in the artifact upload endpoint
145+
// so we need the actual policy in place skipping it is not enough
146+
PolicyArtifactUpload,
147+
// + all the policies from the viewer role inherited automatically
148+
},
149+
}
150+
151+
// ServerOperationsMap is a map of server operations to the ResourceAction tuples that are
152+
// required to perform the operation
153+
// If it contains more than one policy, all of them need to be true
154+
var ServerOperationsMap = map[string][]*Policy{
155+
// Discover endpoint
156+
"/controlplane.v1.ReferrerService/DiscoverPrivate": {PolicyReferrerRead},
157+
// Download/Uploading artifacts
158+
// There are no policies for the download endpoint, we do a manual check in the service layer
159+
// to differentiate between upload and download requests
160+
"/controlplane.v1.CASCredentialsService/Get": {},
161+
// CAS Backend listing
162+
"/controlplane.v1.CASBackendService/List": {PolicyCASBackendList},
163+
// Available integrations
164+
"/controlplane.v1.IntegrationsService/ListAvailable": {PolicyAvailableIntegrationList, PolicyAvailableIntegrationRead},
165+
// Registered integrations
166+
"/controlplane.v1.IntegrationsService/ListRegistrations": {PolicyRegisteredIntegrationList},
167+
// Attached integrations
168+
"/controlplane.v1.IntegrationsService/ListAttachments": {PolicyAttachedIntegrationList},
169+
// Metrics
170+
"/controlplane.v1.OrgMetricsService/.*": {PolicyOrgMetricsRead},
171+
// Robot Account
172+
"/controlplane.v1.RobotAccountService/List": {PolicyRobotAccountList},
173+
// Workflows
174+
"/controlplane.v1.WorkflowService/List": {PolicyWorkflowList},
175+
// WorkflowRun
176+
"/controlplane.v1.WorkflowRunService/List": {PolicyWorkflowRunList},
177+
"/controlplane.v1.WorkflowRunService/View": {PolicyWorkflowRunRead},
178+
// Workflow Contracts
179+
"/controlplane.v1.WorkflowContractService/List": {PolicyWorkflowContractList},
180+
"/controlplane.v1.WorkflowContractService/Describe": {PolicyWorkflowContractRead},
181+
"/controlplane.v1.WorkflowContractService/Update": {PolicyWorkflowContractUpdate},
182+
// Get current information about an organization
183+
"/controlplane.v1.ContextService/Current": {PolicyOrganizationRead, PolicyCASBackendList},
184+
// Listing, create or selecting an organization does not have any required permissions,
185+
// since all the permissions here are in the context of an organization
186+
"/controlplane.v1.OrganizationService/Create": {},
187+
"/controlplane.v1.OrganizationService/SetCurrentMembership": {},
188+
// NOTE: this is about listing my own memberships, not about listing all the memberships in the organization
189+
"/controlplane.v1.OrganizationService/ListMemberships": {},
190+
}
191+
63192
type SubjectAPIToken struct {
64193
ID string
65194
}
@@ -96,6 +225,10 @@ func (e *Enforcer) AddPolicies(sub *SubjectAPIToken, policies ...*Policy) error
96225
return nil
97226
}
98227

228+
func (e *Enforcer) Enforce(sub string, p *Policy) (bool, error) {
229+
return e.Enforcer.Enforce(sub, p.Resource, p.Action)
230+
}
231+
99232
// Remove all the policies for the given subject
100233
func (e *Enforcer) ClearPolicies(sub *SubjectAPIToken) error {
101234
if sub == nil {
@@ -176,5 +309,94 @@ func newEnforcer(a persist.Adapter) (*Enforcer, error) {
176309
return nil, fmt.Errorf("failed to create enforcer: %w", err)
177310
}
178311

312+
// Initialize the enforcer with the roles map
313+
if err := syncRBACRoles(&Enforcer{enforcer}); err != nil {
314+
return nil, fmt.Errorf("failed to sync roles: %w", err)
315+
}
316+
179317
return &Enforcer{enforcer}, nil
180318
}
319+
320+
// Load the roles map into the enforcer
321+
// This is done by adding all the policies defined in the roles map
322+
// and removing all the policies that are not
323+
func syncRBACRoles(e *Enforcer) error {
324+
return doSync(e, rolesMap)
325+
}
326+
327+
func doSync(e *Enforcer, rolesMap map[Role][]*Policy) error {
328+
// Add all the defined policies if they don't exist
329+
for role, policies := range rolesMap {
330+
for _, p := range policies {
331+
// Add policies one by one to skip existing ones.
332+
// This is because the bulk method AddPoliciesEx does not work well with the ent adapter
333+
casbinPolicy := []string{string(role), p.Resource, p.Action}
334+
_, err := e.AddPolicy(casbinPolicy)
335+
if err != nil {
336+
return fmt.Errorf("failed to add policy: %w", err)
337+
}
338+
}
339+
}
340+
341+
// Delete all the policies that are not in the roles map
342+
// 1 - load the policies from the enforcer DB
343+
for _, gotPolicies := range e.GetPolicy() {
344+
role := gotPolicies[0]
345+
policy := &Policy{Resource: gotPolicies[1], Action: gotPolicies[2]}
346+
347+
wantPolicies, ok := rolesMap[Role(role)]
348+
// if the role does not exist in the map, we can delete the policy
349+
if !ok {
350+
_, err := e.RemovePolicy(role, policy.Resource, policy.Action)
351+
if err != nil {
352+
return fmt.Errorf("failed to remove policy: %w", err)
353+
}
354+
continue
355+
}
356+
357+
// We have the role in the map, so we now compare the policies
358+
found := false
359+
for _, p := range wantPolicies {
360+
if p.Resource == policy.Resource && p.Action == policy.Action {
361+
found = true
362+
break
363+
}
364+
}
365+
366+
// If the policy is not in the map, we remove it
367+
if !found {
368+
_, err := e.RemovePolicy(gotPolicies)
369+
if err != nil {
370+
return fmt.Errorf("failed to remove policy: %w", err)
371+
}
372+
}
373+
}
374+
375+
// To finish we make sure that the admin role inherit all the policies from the viewer role
376+
_, err := e.AddGroupingPolicy(string(RoleAdmin), string(RoleViewer))
377+
if err != nil {
378+
return fmt.Errorf("failed to add grouping policy: %w", err)
379+
}
380+
381+
// same for the owner
382+
_, err = e.AddGroupingPolicy(string(RoleOwner), string(RoleAdmin))
383+
if err != nil {
384+
return fmt.Errorf("failed to add grouping policy: %w", err)
385+
}
386+
387+
return nil
388+
}
389+
390+
// Implements https://pkg.go.dev/entgo.io/ent/schema/field#EnumValues
391+
// so they can be added to the database schema
392+
func (Role) Values() (roles []string) {
393+
for _, s := range []Role{
394+
RoleOwner,
395+
RoleAdmin,
396+
RoleViewer,
397+
} {
398+
roles = append(roles, string(s))
399+
}
400+
401+
return
402+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// Copyright 2024 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package authz_test
17+
18+
import (
19+
"fmt"
20+
"testing"
21+
"time"
22+
23+
"github.com/cenkalti/backoff/v4"
24+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/authz"
25+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/biz/testhelpers"
26+
"github.com/google/uuid"
27+
"github.com/stretchr/testify/assert"
28+
"github.com/stretchr/testify/require"
29+
)
30+
31+
func TestMultiReplicaPropagation(t *testing.T) {
32+
// Create two enforcers that share the same database
33+
db := testhelpers.NewTestDatabase(t)
34+
defer db.Close(t)
35+
36+
enforcerA, err := authz.NewDatabaseEnforcer(testhelpers.NewConfData(db, t).Database)
37+
require.NoError(t, err)
38+
enforcerB, err := authz.NewDatabaseEnforcer(testhelpers.NewConfData(db, t).Database)
39+
require.NoError(t, err)
40+
41+
// Subject and policies to add
42+
sub := &authz.SubjectAPIToken{ID: uuid.NewString()}
43+
want := []*authz.Policy{authz.PolicyWorkflowContractList, authz.PolicyWorkflowContractRead}
44+
45+
// Create policies in one enforcer
46+
err = enforcerA.AddPolicies(sub, want...)
47+
require.NoError(t, err)
48+
49+
// Make sure it propagates to the other one
50+
got := enforcerA.GetFilteredPolicy(0, sub.String())
51+
assert.Len(t, got, 2)
52+
53+
// it might take a bit for the policies to propagate to the other enforcer
54+
err = fnWithRetry(func() error {
55+
got = enforcerB.GetFilteredPolicy(0, sub.String())
56+
if len(got) == 2 {
57+
return nil
58+
}
59+
return fmt.Errorf("policies not propagated yet")
60+
})
61+
require.NoError(t, err)
62+
assert.Len(t, got, 2)
63+
64+
// Then delete them from the second one and check propagation again
65+
require.NoError(t, enforcerB.ClearPolicies(sub))
66+
assert.Len(t, enforcerB.GetFilteredPolicy(0, sub.String()), 0)
67+
68+
// Make sure it propagates to the other one
69+
err = fnWithRetry(func() error {
70+
got = enforcerA.GetFilteredPolicy(0, sub.String())
71+
if len(got) == 0 {
72+
return nil
73+
}
74+
75+
return fmt.Errorf("policies not propagated yet")
76+
})
77+
require.NoError(t, err)
78+
assert.Len(t, enforcerA.GetFilteredPolicy(0, sub.String()), 0)
79+
}
80+
81+
func fnWithRetry(f func() error) error {
82+
// Max 1 seconds
83+
return backoff.Retry(f, backoff.WithMaxRetries(backoff.NewConstantBackOff(100*time.Millisecond), 10))
84+
}

0 commit comments

Comments
 (0)