Skip to content

Commit

Permalink
Get project information for users from OpenFGA (#2259)
Browse files Browse the repository at this point in the history
* Get project information for users from OpenFGA

This uses OpenFGA as the authoritative location for user/project
relationships.

* Output user-friendly error messages if default project cannot be determined.
  • Loading branch information
JAORMX authored Feb 2, 2024
1 parent 6fbce7e commit dace861
Show file tree
Hide file tree
Showing 18 changed files with 159 additions and 285 deletions.
46 changes: 0 additions & 46 deletions cmd/server/app/migrate_up.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import (

"github.com/stacklok/minder/internal/authz"
serverconfig "github.com/stacklok/minder/internal/config/server"
"github.com/stacklok/minder/internal/db"
"github.com/stacklok/minder/internal/logger"
)

Expand Down Expand Up @@ -127,55 +126,10 @@ var upCmd = &cobra.Command{
return fmt.Errorf("error preparing authz client: %w", err)
}

store := db.NewStore(dbConn)
if err := migratePermsToFGA(ctx, store, authzw, cmd); err != nil {
return fmt.Errorf("error while migrating permissions to FGA: %w", err)
}

return nil
},
}

func migratePermsToFGA(ctx context.Context, store db.Store, authzw authz.Client, cmd *cobra.Command) error {
cmd.Println("Migrating permissions to FGA...")

var i int32 = 0
for {
userList, err := store.ListUsers(ctx, db.ListUsersParams{Limit: 100, Offset: i})
if err != nil {
return fmt.Errorf("error while listing users: %w", err)
}
i = i + 100
cmd.Printf("Found %d users to migrate\n", len(userList))
if len(userList) == 0 {
break
}

for _, user := range userList {
projs, err := store.GetUserProjects(ctx, user.ID)
if err != nil {
cmd.Printf("Skipping user %d since getting user projects yielded error: %s\n",
user.ID, err)
continue
}

for _, proj := range projs {
cmd.Printf("Migrating user to FGA for project %s\n", proj.ProjectID)
if err := authzw.Write(
ctx, user.IdentitySubject, authz.AuthzRoleAdmin, proj.ProjectID,
); err != nil {
cmd.Printf("Error while writing permission for user %d: %s\n", user.ID, err)
continue
}
}
}
}

cmd.Println("Done migrating permissions to FGA")

return nil
}

func init() {
migrateCmd.AddCommand(upCmd)
}
20 changes: 20 additions & 0 deletions database/migrations/000016_remove_user_project.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Copyright 2024 Stacklok, Inc
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.

-- user/projects
CREATE TABLE user_projects (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE
);
15 changes: 15 additions & 0 deletions database/migrations/000016_remove_user_project.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Copyright 2024 Stacklok, Inc
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.

DROP TABLE IF EXISTS user_projects;
45 changes: 0 additions & 45 deletions database/mock/store.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 0 additions & 10 deletions database/query/user_projects.sql

This file was deleted.

8 changes: 0 additions & 8 deletions database/query/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,5 @@ ORDER BY id
LIMIT $2
OFFSET $3;

-- name: ListUsersByProject :many
SELECT users.* FROM users
JOIN user_projects ON users.id = user_projects.user_id
WHERE user_projects.project_id = $1
ORDER BY users.id
LIMIT $2
OFFSET $3;

-- name: CountUsers :one
SELECT COUNT(*) FROM users;
51 changes: 51 additions & 0 deletions internal/authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,53 @@ func (a *ClientWrapper) AssignmentsToProject(ctx context.Context, project uuid.U
return assignments, nil
}

// ProjectsForUser lists the projects that the given user has access to
func (a *ClientWrapper) ProjectsForUser(ctx context.Context, sub string) ([]uuid.UUID, error) {
u := getUserForTuple(sub)

var pagesize int32 = 50
var contTok *string = nil

projs := map[string]any{}
projectObj := "project:"

for {
resp, err := a.cli.Read(ctx).Options(fgaclient.ClientReadOptions{
PageSize: &pagesize,
ContinuationToken: contTok,
}).Body(fgaclient.ClientReadRequest{
User: &u,
Object: &projectObj,
}).Execute()
if err != nil {
return nil, fmt.Errorf("unable to read authorization tuples: %w", err)
}

for _, t := range resp.GetTuples() {
k := t.GetKey()

projs[k.GetObject()] = struct{}{}
}

if resp.GetContinuationToken() == "" {
break
}

contTok = &resp.ContinuationToken
}

out := []uuid.UUID{}
for proj := range projs {
u, err := uuid.Parse(getProjectFromTuple(proj))
if err != nil {
continue
}
out = append(out, u)
}

return out, nil
}

func getUserForTuple(user string) string {
return "user:" + user
}
Expand All @@ -388,3 +435,7 @@ func getProjectForTuple(project uuid.UUID) string {
func getUserFromTuple(user string) string {
return strings.TrimPrefix(user, "user:")
}

func getProjectFromTuple(project string) string {
return strings.TrimPrefix(project, "project:")
}
3 changes: 3 additions & 0 deletions internal/authz/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ type Client interface {
// AssignmentsToProject outputs the existing role assignments for a given project.
AssignmentsToProject(ctx context.Context, project uuid.UUID) ([]*minderv1.RoleAssignment, error)

// ProjectsForUser outputs the projects a user has access to.
ProjectsForUser(ctx context.Context, sub string) ([]uuid.UUID, error)

// PrepareForRun allows for any preflight configurations to be done before
// the server is started.
PrepareForRun(ctx context.Context) error
Expand Down
5 changes: 5 additions & 0 deletions internal/authz/mock/noop_authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ func (_ *NoopClient) AssignmentsToProject(_ context.Context, _ uuid.UUID) ([]*mi
return nil, nil
}

// ProjectsForUser implements authz.Client
func (_ *NoopClient) ProjectsForUser(_ context.Context, _ string) ([]uuid.UUID, error) {
return nil, nil
}

// PrepareForRun implements authz.Client
func (_ *NoopClient) PrepareForRun(_ context.Context) error {
return nil
Expand Down
7 changes: 6 additions & 1 deletion internal/authz/mock/simple_authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ func (_ *SimpleClient) DeleteUser(_ context.Context, _ string) error {

// AssignmentsToProject implements authz.Client
func (_ *SimpleClient) AssignmentsToProject(_ context.Context, _ uuid.UUID) ([]*minderv1.RoleAssignment, error) {
return nil, nil
return []*minderv1.RoleAssignment{}, nil
}

// ProjectsForUser implements authz.Client
func (n *SimpleClient) ProjectsForUser(_ context.Context, _ string) ([]uuid.UUID, error) {
return n.Allowed, nil
}

// PrepareForRun implements authz.Client
Expand Down
36 changes: 27 additions & 9 deletions internal/controlplane/handlers_authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func EntityContextProjectInterceptor(ctx context.Context, req interface{}, info

server := info.Server.(*Server)

ctx, err := populateEntityContext(ctx, server.store, request)
ctx, err := populateEntityContext(ctx, server.store, server.authzClient, request)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -112,12 +112,17 @@ func ProjectAuthorizationInterceptor(ctx context.Context, req interface{}, info

// populateEntityContext populates the project in the entity context, by looking at the proto context or
// fetching the default project
func populateEntityContext(ctx context.Context, store db.Store, in HasProtoContext) (context.Context, error) {
func populateEntityContext(
ctx context.Context,
store db.Store,
authzClient authz.Client,
in HasProtoContext,
) (context.Context, error) {
if in.GetContext() == nil {
return ctx, fmt.Errorf("context cannot be nil")
}

projectID, err := getProjectFromRequestOrDefault(ctx, store, in)
projectID, err := getProjectFromRequestOrDefault(ctx, store, authzClient, in)
if err != nil {
return ctx, err
}
Expand All @@ -137,7 +142,12 @@ func populateEntityContext(ctx context.Context, store db.Store, in HasProtoConte
return engine.WithEntityContext(ctx, entityCtx), nil
}

func getProjectFromRequestOrDefault(ctx context.Context, store db.Store, in HasProtoContext) (uuid.UUID, error) {
func getProjectFromRequestOrDefault(
ctx context.Context,
store db.Store,
authzClient authz.Client,
in HasProtoContext,
) (uuid.UUID, error) {
// Prefer the context message from the protobuf
if in.GetContext().GetProject() != "" {
requestedProject := in.GetContext().GetProject()
Expand All @@ -152,17 +162,25 @@ func getProjectFromRequestOrDefault(ctx context.Context, store db.Store, in HasP

userInfo, err := store.GetUserBySubject(ctx, subject)
if err != nil {
return uuid.UUID{}, status.Errorf(codes.NotFound, "user not found")
// Note that we're revealing that the user is not registered in minder
// since the caller has a valid token (this is checked in earlier middleware).
// Therefore, we assume it's safe output that the user is not found.
return uuid.UUID{}, util.UserVisibleError(codes.NotFound, "user not found")
}
projects, err := store.GetUserProjects(ctx, userInfo.ID)
projects, err := authzClient.ProjectsForUser(ctx, userInfo.IdentitySubject)
if err != nil {
return uuid.UUID{}, status.Errorf(codes.NotFound, "cannot find projects for user")
return uuid.UUID{}, status.Errorf(codes.Internal, "cannot find projects for user")
}

if len(projects) == 0 {
return uuid.UUID{}, util.UserVisibleError(codes.PermissionDenied, "User has no role grants in projects")
}

if len(projects) != 1 {
return uuid.UUID{}, status.Errorf(codes.InvalidArgument, "cannot get default project")
return uuid.UUID{}, util.UserVisibleError(codes.PermissionDenied, "Cannot determine default project. Please specify one.")
}
return projects[0].ID, nil

return projects[0], nil
}

// Permissions API
Expand Down
Loading

0 comments on commit dace861

Please sign in to comment.