From 8263f2756c031a29db6f8d910aa76dd330be58e6 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 16 May 2024 16:14:19 -0500 Subject: [PATCH 1/4] feat: add hidden enterprise cmd command to list roles This includes custom roles, and has a json ouput option for more granular permissions --- coderd/apidoc/docs.go | 26 +++ coderd/apidoc/swagger.json | 26 +++ coderd/database/dbauthz/dbauthz.go | 5 +- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbmem/dbmem.go | 20 +- coderd/database/dbmetrics/dbmetrics.go | 6 +- coderd/database/dbmock/dbmock.go | 12 +- coderd/database/dump.sql | 5 +- .../000210_custom_role_orgs.down.sql | 3 + .../migrations/000210_custom_role_orgs.up.sql | 5 + coderd/database/models.go | 2 + coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 27 ++- coderd/database/queries/roles.sql | 13 +- coderd/httpapi/name.go | 2 +- coderd/rbac/rolestore/rolestore.go | 5 +- coderd/roles.go | 42 +++- coderd/roles_test.go | 24 ++- codersdk/roles.go | 16 +- docs/api/members.md | 192 ++++++++++++++++-- docs/api/schemas.md | 48 ++++- enterprise/cli/rolescmd.go | 111 ++++++++++ enterprise/cli/root.go | 1 + enterprise/coderd/roles.go | 8 + enterprise/coderd/roles_test.go | 14 +- site/src/api/typesGenerated.ts | 3 +- site/src/testHelpers/entities.ts | 23 ++- 27 files changed, 560 insertions(+), 85 deletions(-) create mode 100644 coderd/database/migrations/000210_custom_role_orgs.down.sql create mode 100644 coderd/database/migrations/000210_custom_role_orgs.up.sql create mode 100644 enterprise/cli/rolescmd.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5883fdb2f47c8..6dde991904811 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8335,11 +8335,37 @@ const docTemplate = `{ "assignable": { "type": "boolean" }, + "built_in": { + "description": "BuiltIn roles are immutable", + "type": "boolean" + }, "display_name": { "type": "string" }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d6684e7cf6c18..d52e3c515d7d2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7400,11 +7400,37 @@ "assignable": { "type": "boolean" }, + "built_in": { + "description": "BuiltIn roles are immutable", + "type": "boolean" + }, "display_name": { "type": "string" }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f3c1ee081eb83..bfb28ece948c3 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -835,11 +835,12 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error { return q.db.CleanTailnetTunnels(ctx) } -func (q *querier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { +// TODO: Handle org scoped lookups +func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAssignRole); err != nil { return nil, err } - return q.db.CustomRolesByName(ctx, lookupRoles) + return q.db.CustomRoles(ctx, arg) } func (q *querier) DeleteAPIKeyByID(ctx context.Context, id string) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index b6d911dc3849a..e2b6171b587c3 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1177,8 +1177,8 @@ func (s *MethodTestSuite) TestUser() { b := dbgen.User(s.T(), db, database.User{}) check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(slice.New(a.ID, b.ID)) })) - s.Run("CustomRolesByName", s.Subtest(func(db database.Store, check *expects) { - check.Args([]string{}).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{}) + s.Run("CustomRoles", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.CustomRolesParams{}).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{}) })) s.Run("Blank/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { // Blank is no perms in the role diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 9c76d04b5a374..a80b5a9d8ca07 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1175,18 +1175,26 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error { return ErrUnimplemented } -func (q *FakeQuerier) CustomRolesByName(_ context.Context, lookupRoles []string) ([]database.CustomRole, error) { +func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { q.mutex.Lock() defer q.mutex.Unlock() found := make([]database.CustomRole, 0) for _, role := range q.data.customRoles { - if slices.ContainsFunc(lookupRoles, func(s string) bool { - return strings.EqualFold(s, role.Name) - }) { - role := role - found = append(found, role) + if len(arg.LookupRoles) > 0 { + if !slices.ContainsFunc(arg.LookupRoles, func(s string) bool { + return strings.EqualFold(s, role.Name) + }) { + continue + } } + + if arg.ExcludeOrgRoles && role.OrganizationID.Valid { + continue + } + + role := role + found = append(found, role) } return found, nil diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index f294b8266c75f..1b59724a6ea21 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -144,10 +144,10 @@ func (m metricsStore) CleanTailnetTunnels(ctx context.Context) error { return r0 } -func (m metricsStore) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { +func (m metricsStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { start := time.Now() - r0, r1 := m.s.CustomRolesByName(ctx, lookupRoles) - m.queryLatencies.WithLabelValues("CustomRolesByName").Observe(time.Since(start).Seconds()) + r0, r1 := m.s.CustomRoles(ctx, arg) + m.queryLatencies.WithLabelValues("CustomRoles").Observe(time.Since(start).Seconds()) return r0, r1 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 157118be65c3a..128b76cfcd0c6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -173,19 +173,19 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), arg0) } -// CustomRolesByName mocks base method. -func (m *MockStore) CustomRolesByName(arg0 context.Context, arg1 []string) ([]database.CustomRole, error) { +// CustomRoles mocks base method. +func (m *MockStore) CustomRoles(arg0 context.Context, arg1 database.CustomRolesParams) ([]database.CustomRole, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CustomRolesByName", arg0, arg1) + ret := m.ctrl.Call(m, "CustomRoles", arg0, arg1) ret0, _ := ret[0].([]database.CustomRole) ret1, _ := ret[1].(error) return ret0, ret1 } -// CustomRolesByName indicates an expected call of CustomRolesByName. -func (mr *MockStoreMockRecorder) CustomRolesByName(arg0, arg1 any) *gomock.Call { +// CustomRoles indicates an expected call of CustomRoles. +func (mr *MockStoreMockRecorder) CustomRoles(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRolesByName", reflect.TypeOf((*MockStore)(nil).CustomRolesByName), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRoles", reflect.TypeOf((*MockStore)(nil).CustomRoles), arg0, arg1) } // DeleteAPIKeyByID mocks base method. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 097f56aff5915..fde9c9556ac84 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -411,11 +411,14 @@ CREATE TABLE custom_roles ( org_permissions jsonb DEFAULT '{}'::jsonb NOT NULL, user_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + organization_id uuid ); COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; +COMMENT ON COLUMN custom_roles.organization_id IS 'Roles can optionally be scoped to an organization'; + CREATE TABLE dbcrypt_keys ( number integer NOT NULL, active_key_digest text, diff --git a/coderd/database/migrations/000210_custom_role_orgs.down.sql b/coderd/database/migrations/000210_custom_role_orgs.down.sql new file mode 100644 index 0000000000000..39b7b0cfed852 --- /dev/null +++ b/coderd/database/migrations/000210_custom_role_orgs.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE custom_roles + -- This column is nullable, meaning no organization scope + DROP COLUMN organization_id; diff --git a/coderd/database/migrations/000210_custom_role_orgs.up.sql b/coderd/database/migrations/000210_custom_role_orgs.up.sql new file mode 100644 index 0000000000000..a4cf2bacff15b --- /dev/null +++ b/coderd/database/migrations/000210_custom_role_orgs.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE custom_roles + -- This column is nullable, meaning no organization scope + ADD COLUMN organization_id uuid; + +COMMENT ON COLUMN custom_roles.organization_id IS 'Roles can optionally be scoped to an organization' diff --git a/coderd/database/models.go b/coderd/database/models.go index 3636f04fd05c5..42c41c83bd5dc 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1790,6 +1790,8 @@ type CustomRole struct { UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + // Roles can optionally be scoped to an organization + OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` } // A table used to store the keys used to encrypt the database. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index cbc76dee5f602..8c75b9dcb53a9 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -48,7 +48,7 @@ type sqlcQuerier interface { CleanTailnetCoordinators(ctx context.Context) error CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error - CustomRolesByName(ctx context.Context, lookupRoles []string) ([]CustomRole, error) + CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index baf124dce9b48..c38de30b4cb84 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5553,18 +5553,33 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) return i, err } -const customRolesByName = `-- name: CustomRolesByName :many +const customRoles = `-- name: CustomRoles :many SELECT - name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at + name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id FROM custom_roles WHERE + true + -- Lookup roles filter + AND CASE WHEN array_length($1 :: text[], 1) > 0 THEN -- Case insensitive name ILIKE ANY($1 :: text []) + ELSE true + END + -- Org scoping filter, to only fetch site wide roles + AND CASE WHEN $2 :: boolean THEN + organization_id IS null + ELSE true + END ` -func (q *sqlQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]CustomRole, error) { - rows, err := q.db.QueryContext(ctx, customRolesByName, pq.Array(lookupRoles)) +type CustomRolesParams struct { + LookupRoles []string `db:"lookup_roles" json:"lookup_roles"` + ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"` +} + +func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) { + rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(arg.LookupRoles), arg.ExcludeOrgRoles) if err != nil { return nil, err } @@ -5580,6 +5595,7 @@ func (q *sqlQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string &i.UserPermissions, &i.CreatedAt, &i.UpdatedAt, + &i.OrganizationID, ); err != nil { return nil, err } @@ -5622,7 +5638,7 @@ ON CONFLICT (name) org_permissions = $4, user_permissions = $5, updated_at = now() -RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at +RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id ` type UpsertCustomRoleParams struct { @@ -5650,6 +5666,7 @@ func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleP &i.UserPermissions, &i.CreatedAt, &i.UpdatedAt, + &i.OrganizationID, ) return i, err } diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index 30ec437e1814e..2137dea34b077 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -1,14 +1,23 @@ --- name: CustomRolesByName :many +-- name: CustomRoles :many SELECT * FROM custom_roles WHERE + true + -- Lookup roles filter + AND CASE WHEN array_length(@lookup_roles :: text[], 1) > 0 THEN -- Case insensitive name ILIKE ANY(@lookup_roles :: text []) + ELSE true + END + -- Org scoping filter, to only fetch site wide roles + AND CASE WHEN @exclude_org_roles :: boolean THEN + organization_id IS null + ELSE true + END ; - -- name: UpsertCustomRole :one INSERT INTO custom_roles ( diff --git a/coderd/httpapi/name.go b/coderd/httpapi/name.go index 0083927c85a08..d8b64a71bdc44 100644 --- a/coderd/httpapi/name.go +++ b/coderd/httpapi/name.go @@ -38,7 +38,7 @@ func UsernameFrom(str string) string { } // NameValid returns whether the input string is a valid name. -// It is a generic validator for any name (user, workspace, template, etc.). +// It is a generic validator for any name (user, workspace, template, role name, etc.). func NameValid(str string) error { if len(str) > 32 { return xerrors.New("must be <= 32 characters") diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 0ed8b2f12fcdb..221b298e51cb5 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -72,7 +72,10 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, // If some roles are missing from the database, they are omitted from // the expansion. These roles are no-ops. Should we raise some kind of // warning when this happens? - dbroles, err := db.CustomRolesByName(ctx, lookup) + dbroles, err := db.CustomRoles(ctx, database.CustomRolesParams{ + LookupRoles: lookup, + ExcludeOrgRoles: false, + }) if err != nil { return nil, xerrors.Errorf("fetch custom roles: %w", err) } diff --git a/coderd/roles.go b/coderd/roles.go index f90f0e474dddf..3d6245f9d4594 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -3,8 +3,11 @@ package coderd import ( "net/http" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/coderd/httpapi" @@ -28,8 +31,25 @@ func (api *API) AssignableSiteRoles(rw http.ResponseWriter, r *http.Request) { return } - roles := rbac.SiteRoles() - httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles)) + dbCustomRoles, err := api.Database.CustomRoles(ctx, database.CustomRolesParams{ + // Only site wide custom roles to be included + ExcludeOrgRoles: true, + LookupRoles: nil, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + customRoles := make([]rbac.Role, 0, len(dbCustomRoles)) + for _, customRole := range dbCustomRoles { + rbacRole, err := rolestore.ConvertDBRole(customRole) + if err == nil { + customRoles = append(customRoles, rbacRole) + } + } + + httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, rbac.SiteRoles(), customRoles)) } // assignableOrgRoles returns all org wide roles that can be assigned. @@ -53,10 +73,10 @@ func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) { } roles := rbac.OrganizationRoles(organization.ID) - httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles)) + httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles, []rbac.Role{})) } -func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []codersdk.AssignableRoles { +func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customRoles []rbac.Role) []codersdk.AssignableRoles { assignable := make([]codersdk.AssignableRoles, 0) for _, role := range roles { // The member role is implied, and not assignable. @@ -66,11 +86,17 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []coder continue } assignable = append(assignable, codersdk.AssignableRoles{ - SlimRole: codersdk.SlimRole{ - Name: role.Name, - DisplayName: role.DisplayName, - }, + Role: db2sdk.Role(role), + Assignable: rbac.CanAssignRole(actorRoles, role.Name), + BuiltIn: true, + }) + } + + for _, role := range customRoles { + assignable = append(assignable, codersdk.AssignableRoles{ + Role: db2sdk.Role(role), Assignable: rbac.CanAssignRole(actorRoles, role.Name), + BuiltIn: false, }) } return assignable diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 6754ddc17c9c2..d82c03033cb54 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -137,18 +138,27 @@ func TestListRoles(t *testing.T) { require.Contains(t, apiErr.Message, c.AuthorizedError) } else { require.NoError(t, err) - require.ElementsMatch(t, c.ExpectedRoles, roles) + ignorePerms := func(f codersdk.AssignableRoles) codersdk.AssignableRoles { + return codersdk.AssignableRoles{ + Role: codersdk.Role{ + Name: f.Name, + DisplayName: f.DisplayName, + }, + Assignable: f.Assignable, + BuiltIn: true, + } + } + expected := db2sdk.List(c.ExpectedRoles, ignorePerms) + found := db2sdk.List(roles, ignorePerms) + require.ElementsMatch(t, expected, found) } }) } } -func convertRole(roleName string) codersdk.SlimRole { +func convertRole(roleName string) codersdk.Role { role, _ := rbac.RoleByName(roleName) - return codersdk.SlimRole{ - DisplayName: role.DisplayName, - Name: role.Name, - } + return db2sdk.Role(role) } func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { @@ -156,7 +166,7 @@ func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { for roleName, assignable := range assignableRoles { role := convertRole(roleName) converted = append(converted, codersdk.AssignableRoles{ - SlimRole: role, + Role: role, Assignable: assignable, }) } diff --git a/codersdk/roles.go b/codersdk/roles.go index 90112f7c6ef30..29b0174931fbe 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -19,8 +19,10 @@ type SlimRole struct { } type AssignableRoles struct { - SlimRole - Assignable bool `json:"assignable"` + Role `table:"r,recursive_inline"` + Assignable bool `json:"assignable" table:"assignable"` + // BuiltIn roles are immutable + BuiltIn bool `json:"built_in" table:"built_in"` } // Permission is the format passed into the rego. @@ -33,12 +35,12 @@ type Permission struct { // Role is a longer form of SlimRole used to edit custom roles. type Role struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - SitePermissions []Permission `json:"site_permissions"` + Name string `json:"name" table:"name,default_sort"` + DisplayName string `json:"display_name" table:"display_name"` + SitePermissions []Permission `json:"site_permissions" table:"site_permissions"` // map[] -> Permissions - OrganizationPermissions map[string][]Permission `json:"organization_permissions"` - UserPermissions []Permission `json:"user_permissions"` + OrganizationPermissions map[string][]Permission `json:"organization_permissions" table:"org_permissions"` + UserPermissions []Permission `json:"user_permissions" table:"user_permissions"` } // PatchRole will upsert a custom site wide role diff --git a/docs/api/members.md b/docs/api/members.md index 43ae4e8f23da1..8b34200e50e95 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -27,8 +27,39 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members [ { "assignable": true, + "built_in": true, "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ] ``` @@ -43,12 +74,63 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------- | ------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» assignable` | boolean | false | | | -| `» display_name` | string | false | | | -| `» name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `[array item]` | array | false | | | +| `» assignable` | boolean | false | | | +| `» built_in` | boolean | false | | Built in roles are immutable | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_permissions` | object | false | | map[] -> Permissions | +| `»» [any property]` | array | false | | | +| `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | +| `»»» negate` | boolean | false | | Negate makes this a negative permission | +| `»»» resource_type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» site_permissions` | array | false | | | +| `» user_permissions` | array | false | | | + +#### Enumerated Values + +| Property | Value | +| --------------- | ----------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -130,8 +212,39 @@ curl -X GET http://coder-server:8080/api/v2/users/roles \ [ { "assignable": true, + "built_in": true, "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ] ``` @@ -146,12 +259,63 @@ curl -X GET http://coder-server:8080/api/v2/users/roles \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------- | ------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» assignable` | boolean | false | | | -| `» display_name` | string | false | | | -| `» name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `[array item]` | array | false | | | +| `» assignable` | boolean | false | | | +| `» built_in` | boolean | false | | Built in roles are immutable | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_permissions` | object | false | | map[] -> Permissions | +| `»» [any property]` | array | false | | | +| `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | +| `»»» negate` | boolean | false | | Negate makes this a negative permission | +| `»»» resource_type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» site_permissions` | array | false | | | +| `» user_permissions` | array | false | | | + +#### Enumerated Values + +| Property | Value | +| --------------- | ----------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index ae35585e2fb12..d1b6c6a3d82e0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -802,18 +802,54 @@ ```json { "assignable": true, + "built_in": true, "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------- | -------- | ------------ | ----------- | -| `assignable` | boolean | false | | | -| `display_name` | string | false | | | -| `name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------------- | --------------------------------------------------- | -------- | ------------ | ---------------------------- | +| `assignable` | boolean | false | | | +| `built_in` | boolean | false | | Built in roles are immutable | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `organization_permissions` | object | false | | map[] -> Permissions | +| » `[any property]` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | ## codersdk.AuditAction diff --git a/enterprise/cli/rolescmd.go b/enterprise/cli/rolescmd.go new file mode 100644 index 0000000000000..49948e1d1214b --- /dev/null +++ b/enterprise/cli/rolescmd.go @@ -0,0 +1,111 @@ +package cli + +import ( + "fmt" + "slices" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +// **NOTE** Only covers site wide roles at present. Org scoped roles maybe +// should be nested under some command that scopes to an org?? + +func (r *RootCmd) roles() *serpent.Command { + cmd := &serpent.Command{ + Use: "roles", + Short: "Manage roles", + Aliases: []string{"role"}, + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Hidden: true, + Children: []*serpent.Command{ + r.showRole(), + }, + } + return cmd +} + +func (r *RootCmd) showRole() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat([]assignableRolesTableRow{}, []string{"name", "display_name", "built_in", "site_permissions", "org_permissions", "user_permissions"}), + func(data any) (any, error) { + input, ok := data.([]codersdk.AssignableRoles) + if !ok { + return nil, xerrors.Errorf("expected []codersdk.AssignableRoles got %T", data) + } + rows := make([]assignableRolesTableRow, 0, len(input)) + for _, role := range input { + rows = append(rows, assignableRolesTableRow{ + Name: role.Name, + DisplayName: role.DisplayName, + SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)), + OrganizationPermissions: fmt.Sprintf("%d organizations", len(role.OrganizationPermissions)), + UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)), + Assignable: role.Assignable, + BuiltIn: role.BuiltIn, + }) + } + return rows, nil + }, + ), + cliui.JSONFormat(), + ) + + client := new(codersdk.Client) + cmd := &serpent.Command{ + Use: "show [role_names ...]", + Short: "Show role(s)", + Middleware: serpent.Chain( + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + roles, err := client.ListSiteRoles(ctx) + if err != nil { + return xerrors.Errorf("listing roles: %w", err) + } + + if len(inv.Args) > 0 { + // filter roles + filtered := make([]codersdk.AssignableRoles, 0) + for _, role := range roles { + if slices.ContainsFunc(inv.Args, func(s string) bool { + return strings.EqualFold(s, role.Name) + }) { + filtered = append(filtered, role) + } + } + roles = filtered + } + + out, err := formatter.Format(inv.Context(), roles) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + formatter.AttachOptions(&cmd.Options) + + return cmd +} + +type assignableRolesTableRow struct { + Name string `table:"name,default_sort"` + DisplayName string `table:"display_name"` + SitePermissions string ` table:"site_permissions"` + // map[] -> Permissions + OrganizationPermissions string `table:"org_permissions"` + UserPermissions string `table:"user_permissions"` + Assignable bool `table:"assignable"` + BuiltIn bool `table:"built_in"` +} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 74615ff0e9d2e..69b686c4174aa 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -17,6 +17,7 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command { r.licenses(), r.groups(), r.provisionerDaemons(), + r.roles(), } } diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 2224e7f25c0bf..552197f7c4401 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -27,6 +27,14 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { return } + if err := httpapi.NameValid(req.Name); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid role name", + Detail: err.Error(), + }) + return + } + if len(req.OrganizationPermissions) > 0 { // Org perms should be assigned only in org specific roles. Otherwise, // it gets complicated to keep track of who can do what. diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 450f80e0b7fe3..c1a0eecad794d 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "bytes" + "slices" "testing" "github.com/stretchr/testify/require" @@ -63,13 +64,12 @@ func TestCustomRole(t *testing.T) { coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) // Verify the role exists in the list - // TODO: Turn this assertion back on when the cli api experience is created. - //allRoles, err := tmplAdmin.ListSiteRoles(ctx) - //require.NoError(t, err) - // - //require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool { - // return selected.Name == role.Name - //}), "role missing from site role list") + allRoles, err := tmplAdmin.ListSiteRoles(ctx) + require.NoError(t, err) + + require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool { + return selected.Name == role.Name + }), "role missing from site role list") }) // Revoked licenses cannot modify/create custom roles, but they can diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 08b1ac2732d82..a809b10220993 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -65,8 +65,9 @@ export interface ArchiveTemplateVersionsResponse { } // From codersdk/roles.go -export interface AssignableRoles extends SlimRole { +export interface AssignableRoles extends Role { readonly assignable: boolean; + readonly built_in: boolean; } // From codersdk/audit.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 5fe1e9cc7b0ff..22a4c5db6edd9 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -229,19 +229,28 @@ export const MockUpdateCheck: TypesGen.UpdateCheckResponse = { version: "v99.999.9999+c9cdf14", }; -export const MockOwnerRole: TypesGen.SlimRole = { +export const MockOwnerRole: TypesGen.Role = { name: "owner", display_name: "Owner", + site_permissions: [], + organization_permissions: {}, + user_permissions: [], }; -export const MockUserAdminRole: TypesGen.SlimRole = { +export const MockUserAdminRole: TypesGen.Role = { name: "user_admin", display_name: "User Admin", + site_permissions: [], + organization_permissions: {}, + user_permissions: [], }; -export const MockTemplateAdminRole: TypesGen.SlimRole = { +export const MockTemplateAdminRole: TypesGen.Role = { name: "template_admin", display_name: "Template Admin", + site_permissions: [], + organization_permissions: {}, + user_permissions: [], }; export const MockMemberRole: TypesGen.SlimRole = { @@ -249,20 +258,24 @@ export const MockMemberRole: TypesGen.SlimRole = { display_name: "Member", }; -export const MockAuditorRole: TypesGen.SlimRole = { +export const MockAuditorRole: TypesGen.Role = { name: "auditor", display_name: "Auditor", + site_permissions: [], + organization_permissions: {}, + user_permissions: [], }; // assignableRole takes a role and a boolean. The boolean implies if the // actor can assign (add/remove) the role from other users. export function assignableRole( - role: TypesGen.SlimRole, + role: TypesGen.Role, assignable: boolean, ): TypesGen.AssignableRoles { return { ...role, assignable: assignable, + built_in: true, }; } From 9fc4ec84db292c6648754c8715f4466b0137fa60 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 16 May 2024 16:35:24 -0500 Subject: [PATCH 2/4] chore: add unit test for show --- coderd/rbac/rolestore/rolestore.go | 2 +- enterprise/cli/rolescmd_test.go | 68 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 enterprise/cli/rolescmd_test.go diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 221b298e51cb5..9881cde028826 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -84,7 +84,7 @@ func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, for _, dbrole := range dbroles { converted, err := ConvertDBRole(dbrole) if err != nil { - return nil, xerrors.Errorf("convert db role %q: %w", dbrole, err) + return nil, xerrors.Errorf("convert db role %q: %w", dbrole.Name, err) } roles = append(roles, converted) cache.Store(dbrole.Name, converted) diff --git a/enterprise/cli/rolescmd_test.go b/enterprise/cli/rolescmd_test.go new file mode 100644 index 0000000000000..df776603e0ac4 --- /dev/null +++ b/enterprise/cli/rolescmd_test.go @@ -0,0 +1,68 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestShowRoles(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, admin := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + // Requires an owner + client, _ := coderdtest.CreateAnotherUser(t, owner, admin.OrganizationID, rbac.RoleOwner()) + + const expectedRole = "test-role" + ctx := testutil.Context(t, testutil.WaitMedium) + _, err := client.PatchRole(ctx, codersdk.Role{ + Name: expectedRole, + DisplayName: "Test Role", + SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead, codersdk.ActionUpdate}, + }), + }) + require.NoError(t, err, "create role") + + inv, conf := newCLI(t, "roles", "show", "test-role") + + pty := ptytest.New(t) + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, conf) + + err = inv.Run() + require.NoError(t, err) + + matches := []string{ + "test-role", "2 permissions", + } + + for _, match := range matches { + pty.ExpectMatch(match) + } + }) +} From 5f83fc47dcd464b37f9dc353fa9ba7aacb9f5be7 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 21 May 2024 07:54:42 -1000 Subject: [PATCH 3/4] PR Comments, add unit test --- coderd/database/dbmem/dbmem.go | 2 +- enterprise/cli/rolescmd.go | 2 +- enterprise/coderd/roles_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index a80b5a9d8ca07..0a8fe6e24a8a6 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1181,6 +1181,7 @@ func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesPar found := make([]database.CustomRole, 0) for _, role := range q.data.customRoles { + role := role if len(arg.LookupRoles) > 0 { if !slices.ContainsFunc(arg.LookupRoles, func(s string) bool { return strings.EqualFold(s, role.Name) @@ -1193,7 +1194,6 @@ func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesPar continue } - role := role found = append(found, role) } diff --git a/enterprise/cli/rolescmd.go b/enterprise/cli/rolescmd.go index 49948e1d1214b..b0a9346697a01 100644 --- a/enterprise/cli/rolescmd.go +++ b/enterprise/cli/rolescmd.go @@ -18,7 +18,7 @@ import ( func (r *RootCmd) roles() *serpent.Command { cmd := &serpent.Command{ Use: "roles", - Short: "Manage roles", + Short: "Manage site-wide roles.", Aliases: []string{"role"}, Handler: func(inv *serpent.Invocation) error { return inv.Command.HelpHandler(inv) diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index c1a0eecad794d..67b863e63bacd 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -167,4 +167,37 @@ func TestCustomRole(t *testing.T) { }) require.ErrorContains(t, err, "forbidden") }) + + t.Run("InvalidName", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, _ := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + //nolint:gocritic // owner is required for this + _, err := owner.PatchRole(ctx, codersdk.Role{ + Name: "Bad_Name", // No underscores allowed + DisplayName: "Testing Purposes", + // Basically creating a template admin manually + SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights}, + codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead}, + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), + OrganizationPermissions: nil, + UserPermissions: nil, + }) + require.ErrorContains(t, err, "Invalid role name") + }) } From 0d9074c5fd8371f3e0efb5a912038520e57b027d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 21 May 2024 08:01:41 -1000 Subject: [PATCH 4/4] Bump migration number --- ...custom_role_orgs.down.sql => 000212_custom_role_orgs.down.sql} | 0 ...210_custom_role_orgs.up.sql => 000212_custom_role_orgs.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000210_custom_role_orgs.down.sql => 000212_custom_role_orgs.down.sql} (100%) rename coderd/database/migrations/{000210_custom_role_orgs.up.sql => 000212_custom_role_orgs.up.sql} (100%) diff --git a/coderd/database/migrations/000210_custom_role_orgs.down.sql b/coderd/database/migrations/000212_custom_role_orgs.down.sql similarity index 100% rename from coderd/database/migrations/000210_custom_role_orgs.down.sql rename to coderd/database/migrations/000212_custom_role_orgs.down.sql diff --git a/coderd/database/migrations/000210_custom_role_orgs.up.sql b/coderd/database/migrations/000212_custom_role_orgs.up.sql similarity index 100% rename from coderd/database/migrations/000210_custom_role_orgs.up.sql rename to coderd/database/migrations/000212_custom_role_orgs.up.sql pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy