Skip to content

chore: ensure default org always exists #12412

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 5, 2024
Prev Previous commit
Next Next commit
correctly return implied roles
  • Loading branch information
Emyrk committed Mar 4, 2024
commit b83062521021ab6bb4412d38dfb440b02c57c80b
43 changes: 36 additions & 7 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,7 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) {
}

resp := codersdk.UserRoles{
Roles: user.RBACRoles,
Roles: make([]string, 0),
OrganizationRoles: make(map[uuid.UUID][]string),
}

Expand All @@ -1042,10 +1042,40 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) {
return
}

// We have to handle rbac permissions manually here. The 'GetAuthorizationUserRoles' does
// not have the ability to check permissions itself.
// The query 'GetAuthorizationUserRoles' handles implied roles, so it is
// the source of truth.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so a lot to unpack here.

  1. I don't understand what this has to do with ensuring the default organization exists. Why is it necessary in this PR?

  2. Why is it necessary to query AsSystemRestricted and then filter in this function? This kind of logic needs to live in the RBAC layer!

  3. It looks like the logic is that if you can read the user's UserDataRBAC at global scope and are just a member of the organization, then you can see their roles in that organization. This is an example of shadow access control where we are computing a permission based on combinations of other permissions, and is going to be undocumented and hard to understand.

    The problem here is that we made UserDataRBAC a deployment-scoped resource, but some of the information is not actually global scope (organization roles). Fix the resource scoping issue, don't hack permissions out of other permissions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what this has to do with ensuring the default organization exists. Why is it necessary in this PR?

Yea, this came up as a result of this PR fixing up some unit tests that behavior changed. One of them was we used to insert the OrgMember() role explicitly. An oversight, as this query adds in implied roles automatically:

-- This function returns roles for authorization purposes. Implied member roles
-- are included.

So GetAuthorizationUserRoles is the source of truth. This api call was just returning the []string field Roles, which is not correct. So this API call should be using GetAuthorizationUserRoles.

Why is it necessary to query AsSystemRestricted and then filter in this function? This kind of logic needs to live in the RBAC layer!

I can make the function no longer a system function, and filter in the dbauthz layer, but then all calls to this function get an added cost. Which happens on every single api request.

func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (database.GetAuthorizationUserRolesRow, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
return database.GetAuthorizationUserRolesRow{}, err
}

I think this is a system function as an optimization, since this is just a heavily used function.

It looks like the logic is that if you can read the user's UserDataRBAC at global scope and are just a member of the organization, then you can see their roles in that organization. This is an example of shadow access control where we are computing a permission based on combinations of other permissions, and is going to be undocumented and hard to understand.

It is. I can implement the filter in the dbauthz, but I didn't want to add the cost to everything. But I want to use the correct function for the source of truth for roles.

In general, our ability to relate perms is weak because the policy execution can only take 1 object as input at a time. So the relations are enforced outside of it.


For getting through this PR I could:

  1. Just drop organization roles from this output. We don't use them anywhere since orgs are not really a concept atm. We can add another endpoint for listing their roles of a given org later.
  2. Do this in dbauthz, and it would look something akin to this:
func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (database.GetAuthorizationUserRolesRow, error) {
	// System context can read everything, so do not check any further permissions.
	systemCan := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem)
	if systemCan == nil {
		return q.db.GetAuthorizationUserRoles(ctx, userID)
	}

	// If the actor is a not a system, they might be able to read some of the roles.
	// Check if they can read the user.
	if err := q.authorizeContext(ctx, rbac.ActionRead,
		rbac.ResourceUserData.WithOwner(userID.String()).WithID(userID)); err != nil {
		return database.GetAuthorizationUserRolesRow{}, err
	}

	authorizationInfo, err := q.db.GetAuthorizationUserRoles(ctx, userID)
	if err != nil {
		return database.GetAuthorizationUserRolesRow{}, err
	}
	allowedRoles := make([]string, 0, len(authorizationInfo.Roles))

	// memberships is a map of orgID to whether the actor context can read the org membership.
	memberships := make(map[uuid.UUID]bool)
	for _, role := range authorizationInfo.Roles {
		if orgIDStr, ok := rbac.IsOrgRole(role); ok {
			orgID, err := uuid.Parse(orgIDStr)
			if err != nil {
				continue
			}

			// If we can read the org membership for this org, then we can read the role.
			// So include it.
			if _, ok := memberships[orgID]; !ok {
				canReadMem := q.authorizeContext(ctx, rbac.ActionRead, database.OrganizationMember{
					UserID:         userID,
					OrganizationID: orgID,
				})
				memberships[orgID] = canReadMem == nil
			}

			if memberships[orgID] {
				allowedRoles = append(allowedRoles, role)
			}
		} else {
			allowedRoles = append(allowedRoles, role)
		}
	}
	
	// TODO: authorizationInfo.Groups should also be filtered?
	authorizationInfo.Roles = allowedRoles
	return authorizationInfo, nil
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option 3, and the one I might just do. Is just not include the implied roles in the api response. The previous behavior was incorrectly omitting these, so this PR does not need to resolve this behavior.

I made an issue to fix this later: #12427

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, our ability to relate perms is weak because the policy execution can only take 1 object as input at a time. So the relations are enforced outside of it.

It's actually the relations themselves that I also have a problem with. We've got this global-scoped permission: read UserDataRBAC that is granting access to org-scoped data via a tensor product with org membership. That makes it impossible to grant access just to the org-scoped data in a single org. If you have access to it in one org you have access to it in every org you are a member of.

It's just a confusing mess is you start allowing permissions to beget other permissions. Roles determine the permissions you have in RBAC. We need an org-scoped resource that represents the roles a user has in that org, and then to assign read permission on that resource to one or more roles in our system.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@spikecurtis Yes, this endpoint is incorrectly assuming UserDataRBAC should access the org roles. This is a mistake

// nolint: gocritic // System is the only actor who can fetch these.
roles, err := api.Database.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user's roles.",
Detail: err.Error(),
})
return
}

allowedOrgs := make(map[uuid.UUID]struct{})
for _, mem := range memberships {
// If we can read the org member, include the roles.
if err == nil {
resp.OrganizationRoles[mem.OrganizationID] = mem.Roles
allowedOrgs[mem.OrganizationID] = struct{}{}
}

// Only return roles the user can read.
for _, role := range roles.Roles {
orgID, ok := rbac.IsOrgRole(role)
if !ok {
resp.Roles = append(resp.Roles, role)
continue
}

uid, err := uuid.Parse(orgID)
if err != nil {
continue // This should never happen
}
if _, ok := allowedOrgs[uid]; ok {
resp.OrganizationRoles[uid] = append(resp.OrganizationRoles[uid], role)
continue
}
}

Expand Down Expand Up @@ -1257,9 +1287,8 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
// TODO: When organizations are allowed to be created, we should
// come back to determining the default role of the person who
// creates the org. Until that happens, all users in an organization
// should be just regular members.
orgRoles = append(orgRoles, rbac.RoleOrgMember(req.OrganizationID))

// should be just regular members. Membership role is implied, and
// not required to be explicit.
_, err = tx.InsertAllUsersGroup(ctx, organization.ID)
if err != nil {
return xerrors.Errorf("create %q group: %w", database.EveryoneGroup, err)
Expand Down
3 changes: 2 additions & 1 deletion coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1091,11 +1091,12 @@ func TestInitialRoles(t *testing.T) {
require.NoError(t, err)
require.ElementsMatch(t, roles.Roles, []string{
rbac.RoleOwner(),
rbac.RoleMember(),
}, "should be a member and admin")

require.ElementsMatch(t, roles.OrganizationRoles[first.OrganizationID], []string{
rbac.RoleOrgMember(first.OrganizationID),
}, "should be a member and admin")
}, "should be a member")
}

func TestPutUserSuspend(t *testing.T) {
Expand Down