coderd/organizationskills_acl_test.go
Rama main · réplica de lectura del repositorio original.
package coderd_test
import (
"context"
"encoding/json"
"net/http"
"sync/atomic"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestOrganizationSkillACL(t *testing.T) {
t.Parallel()
mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map {
oldSkill, oldOK := old.(database.AuditableOrganizationSkill)
newSkill, newOK := newVal.(database.AuditableOrganizationSkill)
if !oldOK || !newOK {
return audit.Map{}
}
return audit.Map{
"group_acl": {Old: oldSkill.GroupACL, New: newSkill.GroupACL},
"user_acl": {Old: oldSkill.UserACL, New: newSkill.UserACL},
}
})
db, ps := dbtestutil.NewDB(t)
ownerRawClient := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps, Auditor: mAudit})
firstUser := coderdtest.CreateFirstUser(t, ownerRawClient)
orgID := firstUser.OrganizationID
owner := codersdk.NewExperimentalClient(ownerRawClient)
orgAdminRawClient, _ := coderdtest.CreateAnotherUser(t, ownerRawClient, orgID, rbac.ScopedRoleOrgAdmin(orgID))
memberRawClient, member := coderdtest.CreateAnotherUser(t, ownerRawClient, orgID)
memberClient := codersdk.NewExperimentalClient(memberRawClient)
otherOrg := dbgen.Organization(t, db, database.Organization{})
foreignUser := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: otherOrg.ID, UserID: foreignUser.ID})
readACL := func(ids ...uuid.UUID) database.ChatACL {
entries := database.ChatACL{}
for _, id := range ids {
entries[id.String()] = database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}}
}
return entries
}
createSkill := func(ctx context.Context, t *testing.T, name string) codersdk.Skill {
t.Helper()
skill, err := owner.CreateOrganizationSkill(ctx, orgID, codersdk.CreateSkillRequest{
Content: userSkillMarkdown(name, "ACL", "Body."),
})
require.NoError(t, err)
return skill
}
// The site owner shares in every other subtest. Listing candidates is
// shared with MCP server configs and covered by
// TestMCPServerConfigACLAvailable.
t.Run("OrgAdminManagesACL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := codersdk.NewExperimentalClient(orgAdminRawClient)
skill := createSkill(ctx, t, "org-admin-sharer")
acl, err := client.OrganizationSkillACL(ctx, orgID, skill.Name)
require.NoError(t, err)
require.Empty(t, acl.Users)
require.Len(t, acl.Groups, 1)
require.Equal(t, orgID, acl.Groups[0].ID)
require.Equal(t, codersdk.OrganizationSkillRoleRead, acl.Groups[0].Role)
available, err := client.OrganizationSkillACLAvailable(ctx, orgID, skill.Name, codersdk.UsersRequest{})
require.NoError(t, err)
require.Contains(t, aclAvailableUserIDs(available), member.ID)
require.NotContains(t, aclAvailableUserIDs(available), foreignUser.ID)
err = client.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{
UserRoles: map[string]codersdk.OrganizationSkillRole{member.ID.String(): codersdk.OrganizationSkillRoleRead},
})
require.NoError(t, err)
acl, err = client.OrganizationSkillACL(ctx, orgID, skill.Name)
require.NoError(t, err)
require.Len(t, acl.Users, 1)
require.Equal(t, member.ID, acl.Users[0].ID)
require.Equal(t, codersdk.OrganizationSkillRoleRead, acl.Users[0].Role)
})
t.Run("ReadersGetNotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
skill := createSkill(ctx, t, "non-sharer-skill")
_, err := memberClient.OrganizationSkillByName(ctx, orgID, skill.Name)
require.NoError(t, err)
_, err = memberClient.OrganizationSkillACL(ctx, orgID, skill.Name)
requireSDKErrorStatus(t, err, http.StatusNotFound)
err = memberClient.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{
UserRoles: map[string]codersdk.OrganizationSkillRole{member.ID.String(): codersdk.OrganizationSkillRoleRead},
})
requireSDKErrorStatus(t, err, http.StatusNotFound)
_, err = memberClient.OrganizationSkillACLAvailable(ctx, orgID, skill.Name, codersdk.UsersRequest{})
requireSDKErrorStatus(t, err, http.StatusNotFound)
})
t.Run("SparseUpdateChangesReaders", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
group := dbgen.Group(t, db, database.Group{OrganizationID: orgID})
groupMemberRawClient, groupMember := coderdtest.CreateAnotherUser(t, ownerRawClient, orgID)
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: groupMember.ID})
addedRawClient, added := coderdtest.CreateAnotherUser(t, ownerRawClient, orgID)
skill := createSkill(ctx, t, "sparse-acl-skill")
requireOrganizationSkillListed(ctx, t, memberClient, orgID, skill.Name)
err := owner.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{
UserRoles: map[string]codersdk.OrganizationSkillRole{added.ID.String(): codersdk.OrganizationSkillRoleRead},
GroupRoles: map[string]codersdk.OrganizationSkillRole{
group.ID.String(): codersdk.OrganizationSkillRoleRead,
orgID.String(): codersdk.OrganizationSkillRoleDeleted,
},
})
require.NoError(t, err)
requireOrganizationSkillNotListed(ctx, t, memberClient, orgID, skill.Name)
_, err = memberClient.OrganizationSkillByName(ctx, orgID, skill.Name)
requireSDKErrorStatus(t, err, http.StatusNotFound)
for _, rawClient := range []*codersdk.Client{groupMemberRawClient, addedRawClient} {
client := codersdk.NewExperimentalClient(rawClient)
requireOrganizationSkillListed(ctx, t, client, orgID, skill.Name)
_, err = client.OrganizationSkillByName(ctx, orgID, skill.Name)
require.NoError(t, err)
}
var log database.AuditLog
for _, entry := range mAudit.AuditLogs() {
if entry.ResourceID == skill.ID && entry.Action == database.AuditActionWrite {
log = entry
}
}
require.Equal(t, database.ResourceTypeOrganizationSkill, log.ResourceType)
require.Equal(t, orgID, log.OrganizationID)
var diff map[string]struct {
Old database.ChatACL `json:"old"`
New database.ChatACL `json:"new"`
}
require.NoError(t, json.Unmarshal(log.Diff, &diff))
require.Equal(t, readACL(orgID), diff["group_acl"].Old)
require.Equal(t, readACL(group.ID), diff["group_acl"].New)
require.Equal(t, database.ChatACL{}, diff["user_acl"].Old)
require.Equal(t, readACL(added.ID), diff["user_acl"].New)
// Removing principals that are no longer granted, or no longer exist,
// leaves the ACL unchanged.
err = owner.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{
UserRoles: map[string]codersdk.OrganizationSkillRole{uuid.NewString(): codersdk.OrganizationSkillRoleDeleted},
GroupRoles: map[string]codersdk.OrganizationSkillRole{orgID.String(): codersdk.OrganizationSkillRoleDeleted},
})
require.NoError(t, err)
acl, err := owner.OrganizationSkillACL(ctx, orgID, skill.Name)
require.NoError(t, err)
require.Len(t, acl.Users, 1)
require.Equal(t, added.ID, acl.Users[0].ID)
require.Len(t, acl.Groups, 1)
require.Equal(t, group.ID, acl.Groups[0].ID)
require.Equal(t, 1, acl.Groups[0].TotalMemberCount)
})
// Membership, duplicate-spelling and cross-organization validation is
// shared with MCP server configs and covered by TestMCPServerConfigACL.
t.Run("RejectsUnsupportedRole", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
skill := createSkill(ctx, t, "invalid-acl-skill")
err := owner.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{
UserRoles: map[string]codersdk.OrganizationSkillRole{member.ID.String(): "use"},
})
sdkErr := requireSDKErrorStatus(t, err, http.StatusBadRequest)
require.Contains(t, sdkErr.Error(), `role "use" is not a valid organization skill role`)
acl, err := owner.OrganizationSkillACL(ctx, orgID, skill.Name)
require.NoError(t, err)
require.Empty(t, acl.Users)
require.Len(t, acl.Groups, 1)
require.Equal(t, orgID, acl.Groups[0].ID)
})
t.Run("ScopedAPIKeys", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
skill := createSkill(ctx, t, "scoped-acl-skill")
scopedClient := func(scopes ...database.APIKeyScope) *codersdk.ExperimentalClient {
_, token := dbgen.APIKey(t, db, database.APIKey{UserID: firstUser.UserID, Scopes: scopes})
rawClient := codersdk.New(ownerRawClient.URL)
rawClient.SetSessionToken(token)
return codersdk.NewExperimentalClient(rawClient)
}
req := codersdk.UpdateOrganizationSkillACLRequest{
UserRoles: map[string]codersdk.OrganizationSkillRole{member.ID.String(): codersdk.OrganizationSkillRoleRead},
}
reader := scopedClient("organization:read", "organization_skill:read")
_, err := reader.OrganizationSkillByName(ctx, orgID, skill.Name)
require.NoError(t, err)
err = reader.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, req)
requireSDKErrorStatus(t, err, http.StatusNotFound)
sharer := scopedClient("organization:read", "organization_skill:share")
require.NoError(t, sharer.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, req))
})
}
// organizationSkillDeleteRaceStore deletes the skill right after the param
// middleware read once armed, so the handler's locked re-fetch sees a
// concurrently deleted row.
type organizationSkillDeleteRaceStore struct {
database.Store
armed atomic.Bool
}
func (s *organizationSkillDeleteRaceStore) GetOrganizationSkillByOrganizationIDAndName(ctx context.Context, arg database.GetOrganizationSkillByOrganizationIDAndNameParams) (database.Skill, error) {
skill, err := s.Store.GetOrganizationSkillByOrganizationIDAndName(ctx, arg)
if err == nil && s.armed.CompareAndSwap(true, false) {
if _, err := s.DeleteOrganizationSkillByOrganizationIDAndName(ctx, database.DeleteOrganizationSkillByOrganizationIDAndNameParams(arg)); err != nil {
return database.Skill{}, err
}
}
return skill, err
}
func TestOrganizationSkillACLConcurrentDelete(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
db, ps := dbtestutil.NewDB(t)
store := &organizationSkillDeleteRaceStore{Store: db}
ownerRawClient := coderdtest.New(t, &coderdtest.Options{Database: store, Pubsub: ps})
firstUser := coderdtest.CreateFirstUser(t, ownerRawClient)
client := codersdk.NewExperimentalClient(ownerRawClient)
skill, err := client.CreateOrganizationSkill(ctx, firstUser.OrganizationID, codersdk.CreateSkillRequest{
Content: userSkillMarkdown("acl-delete-race", "ACL", "Body."),
})
require.NoError(t, err)
store.armed.Store(true)
err = client.UpdateOrganizationSkillACL(ctx, firstUser.OrganizationID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{})
requireSDKErrorStatus(t, err, http.StatusNotFound)
}
func TestOrganizationSkillACLMemberRemoval(t *testing.T) {
t.Parallel()
db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
ownerRawClient := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps})
firstUser := coderdtest.CreateFirstUser(t, ownerRawClient)
orgID := firstUser.OrganizationID
owner := codersdk.NewExperimentalClient(ownerRawClient)
// Each case starts from a skill without the Everyone grant so a rejoined
// member can only read it through a leftover direct grant.
setup := func(ctx context.Context, t *testing.T, name string) (codersdk.Skill, *codersdk.ExperimentalClient, uuid.UUID) {
t.Helper()
skill, err := owner.CreateOrganizationSkill(ctx, orgID, codersdk.CreateSkillRequest{
Content: userSkillMarkdown(name, "ACL", "Body."),
})
require.NoError(t, err)
require.NoError(t, owner.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{
GroupRoles: map[string]codersdk.OrganizationSkillRole{orgID.String(): codersdk.OrganizationSkillRoleDeleted},
}))
rawClient, user := coderdtest.CreateAnotherUser(t, ownerRawClient, orgID)
return skill, codersdk.NewExperimentalClient(rawClient), user.ID
}
requireRejoinedMemberCannotRead := func(ctx context.Context, t *testing.T, skill codersdk.Skill, client *codersdk.ExperimentalClient, userID uuid.UUID) {
t.Helper()
var granted bool
require.NoError(t, sqlDB.QueryRowContext(ctx, `SELECT user_acl ? $2 FROM skills WHERE id = $1`, skill.ID, userID.String()).Scan(&granted))
require.False(t, granted)
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: orgID, UserID: userID})
_, err := client.OrganizationSkillByName(ctx, orgID, skill.Name)
requireSDKErrorStatus(t, err, http.StatusNotFound)
}
t.Run("RemovalBeforeGrant", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
skill, client, userID := setup(ctx, t, "removal-before-grant")
tx, err := sqlDB.BeginTx(ctx, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback() })
_, err = tx.ExecContext(ctx, `DELETE FROM organization_members WHERE organization_id = $1 AND user_id = $2`, orgID, userID)
require.NoError(t, err)
patchErr := make(chan error, 1)
go func() {
patchErr <- owner.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{
UserRoles: map[string]codersdk.OrganizationSkillRole{userID.String(): codersdk.OrganizationSkillRoleRead},
})
}()
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
if len(patchErr) > 0 {
return true
}
var waits int
err := sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid() AND wait_event_type = 'Lock'`).Scan(&waits)
return err == nil && waits > 0
}, testutil.IntervalFast, "PATCH returns or waits for the membership lock")
require.NoError(t, tx.Commit())
err = testutil.RequireReceive(ctx, t, patchErr)
sdkErr := requireSDKErrorStatus(t, err, http.StatusBadRequest)
require.Contains(t, sdkErr.Error(), "user "+userID.String()+" does not belong to organization")
requireRejoinedMemberCannotRead(ctx, t, skill, client, userID)
})
t.Run("RemovalAfterGrant", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
skill, client, userID := setup(ctx, t, "removal-after-grant")
require.NoError(t, owner.UpdateOrganizationSkillACL(ctx, orgID, skill.Name, codersdk.UpdateOrganizationSkillACLRequest{
UserRoles: map[string]codersdk.OrganizationSkillRole{userID.String(): codersdk.OrganizationSkillRoleRead},
}))
_, err := client.OrganizationSkillByName(ctx, orgID, skill.Name)
require.NoError(t, err)
require.NoError(t, ownerRawClient.DeleteOrganizationMember(ctx, orgID, userID.String()))
requireRejoinedMemberCannotRead(ctx, t, skill, client, userID)
})
}
func aclAvailableUserIDs(available codersdk.ACLAvailable) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(available.Users))
for _, user := range available.Users {
ids = append(ids, user.ID)
}
return ids
}