Skip to content

Commit

Permalink
Add Users API (#144)
Browse files Browse the repository at this point in the history
This doesn't actually operate on a resource, as users themselves are
federated from another source of truth, but we can at least make a meta
API out of the data contained in groups.  This allows us to easily add
and remove users to groups, rather than having to manually do it per
group.
  • Loading branch information
spjmurray authored Jan 9, 2025
1 parent 6cc4a90 commit 0b056cd
Show file tree
Hide file tree
Showing 15 changed files with 1,576 additions and 309 deletions.
4 changes: 2 additions & 2 deletions charts/identity/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ description: A Helm chart for deploying Unikorn's IdP

type: application

version: v0.2.48
appVersion: v0.2.48
version: v0.2.49
appVersion: v0.2.49

icon: https://raw.githubusercontent.com/unikorn-cloud/assets/main/images/logos/dark-on-light/icon.png

Expand Down
7 changes: 7 additions & 0 deletions charts/identity/crds/identity.unikorn-cloud.org_groups.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ spec:
items:
type: string
type: array
serviceAccountIDs:
description: |-
ServiceAccountIDs are a list of service accounts that are members of
the group.
items:
type: string
type: array
tags:
description: Tags are aribrary user data.
items:
Expand Down
2 changes: 2 additions & 0 deletions charts/identity/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ roles:
identity:oauth2providers: [create,read,update,delete]
identity:roles: [create,read,update,delete]
identity:serviceaccounts: [create,read,update,delete]
identity:users: [create,read,update,delete]
identity:groups: [create,read,update,delete]
identity:projects: [create,read,update,delete]
region:regions: [create,read,update,delete]
Expand Down Expand Up @@ -134,6 +135,7 @@ roles:
identity:organizations: [read,update]
identity:oauth2providers: [create,read,update,delete]
identity:serviceaccounts: [create,read,update,delete]
identity:users: [create,read,update,delete]
identity:roles: [create,read,update,delete]
identity:groups: [create,read,update,delete]
identity:projects: [create,read,update,delete]
Expand Down
3 changes: 3 additions & 0 deletions pkg/apis/unikorn/v1alpha1/group_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ type GroupSpec struct {
ProviderGroupNames []string `json:"providerGroupNames,omitempty"`
// Users are a list of user names that are members of the group.
Users []string `json:"users,omitempty"`
// ServiceAccountIDs are a list of service accounts that are members of
// the group.
ServiceAccountIDs []string `json:"serviceAccountIDs,omitempty"`
// RoleIDs are a list of roles users of the group inherit.
RoleIDs []string `json:"roleIDs,omitempty"`
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/apis/unikorn/v1alpha1/zz_generated.deepcopy.go

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

82 changes: 82 additions & 0 deletions pkg/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/unikorn-cloud/identity/pkg/handler/projects"
"github.com/unikorn-cloud/identity/pkg/handler/roles"
"github.com/unikorn-cloud/identity/pkg/handler/serviceaccounts"
"github.com/unikorn-cloud/identity/pkg/handler/users"
"github.com/unikorn-cloud/identity/pkg/jose"
"github.com/unikorn-cloud/identity/pkg/middleware/authorization"
"github.com/unikorn-cloud/identity/pkg/oauth2"
Expand Down Expand Up @@ -661,3 +662,84 @@ func (h *Handler) PostApiV1OrganizationsOrganizationIDServiceaccountsServiceAcco
h.setUncacheable(w)
util.WriteJSONResponse(w, r, http.StatusOK, result)
}

func (h *Handler) usersClient() *users.Client {
return users.New(h.client, h.namespace)
}

func (h *Handler) GetApiV1OrganizationsOrganizationIDUsers(w http.ResponseWriter, r *http.Request, organizationID openapi.OrganizationIDParameter) {
if err := rbac.AllowOrganizationScope(r.Context(), "identity:users", openapi.Read, organizationID); err != nil {
errors.HandleError(w, r, err)
return
}

result, err := h.usersClient().List(r.Context(), organizationID)
if err != nil {
errors.HandleError(w, r, err)
return
}

h.setUncacheable(w)
util.WriteJSONResponse(w, r, http.StatusOK, result)
}

func (h *Handler) PostApiV1OrganizationsOrganizationIDUsers(w http.ResponseWriter, r *http.Request, organizationID openapi.OrganizationIDParameter) {
if err := rbac.AllowOrganizationScope(r.Context(), "identity:users", openapi.Create, organizationID); err != nil {
errors.HandleError(w, r, err)
return
}

request := &openapi.User{}

if err := util.ReadJSONBody(r, request); err != nil {
errors.HandleError(w, r, err)
return
}

result, err := h.usersClient().Create(r.Context(), organizationID, request)
if err != nil {
errors.HandleError(w, r, err)
return
}

h.setUncacheable(w)
util.WriteJSONResponse(w, r, http.StatusCreated, result)
}

func (h *Handler) DeleteApiV1OrganizationsOrganizationIDUsersUsername(w http.ResponseWriter, r *http.Request, organizationID openapi.OrganizationIDParameter, username openapi.UsernameParameter) {
if err := rbac.AllowOrganizationScope(r.Context(), "identity:users", openapi.Delete, organizationID); err != nil {
errors.HandleError(w, r, err)
return
}

if err := h.usersClient().Delete(r.Context(), organizationID, username); err != nil {
errors.HandleError(w, r, err)
return
}

h.setUncacheable(w)
w.WriteHeader(http.StatusAccepted)
}

func (h *Handler) PutApiV1OrganizationsOrganizationIDUsersUsername(w http.ResponseWriter, r *http.Request, organizationID openapi.OrganizationIDParameter, username openapi.UsernameParameter) {
if err := rbac.AllowOrganizationScope(r.Context(), "identity:users", openapi.Update, organizationID); err != nil {
errors.HandleError(w, r, err)
return
}

request := &openapi.User{}

if err := util.ReadJSONBody(r, request); err != nil {
errors.HandleError(w, r, err)
return
}

result, err := h.usersClient().Update(r.Context(), organizationID, username, request)
if err != nil {
errors.HandleError(w, r, err)
return
}

h.setUncacheable(w)
util.WriteJSONResponse(w, r, http.StatusOK, result)
}
93 changes: 51 additions & 42 deletions pkg/handler/serviceaccounts/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func convert(in *unikornv1.ServiceAccount, groups *unikornv1.GroupList) *openapi
memberGroups := groups.DeepCopy()

memberGroups.Items = slices.DeleteFunc(memberGroups.Items, func(group unikornv1.Group) bool {
return !slices.Contains(group.Spec.Users, in.Labels[constants.NameLabel])
return !slices.Contains(group.Spec.ServiceAccountIDs, in.Labels[constants.NameLabel])
})

var memberGroupIDs openapi.GroupIDs
Expand Down Expand Up @@ -124,6 +124,7 @@ func convertCreate(in *unikornv1.ServiceAccount, groups *unikornv1.GroupList) *o
return out
}

// convertList converts a list of Kubernetes objects into OpenAPI ones.
func convertList(in *unikornv1.ServiceAccountList, groups *unikornv1.GroupList) openapi.ServiceAccounts {
out := make(openapi.ServiceAccounts, len(in.Items))

Expand All @@ -134,16 +135,47 @@ func convertList(in *unikornv1.ServiceAccountList, groups *unikornv1.GroupList)
return out
}

// generateAccessToken generates a service account token for the given service account.
func (c *Client) generateAccessToken(ctx context.Context, organization *organizations.Meta, serviceAccountID string) (*oauth2.Tokens, error) {
issueInfo := &oauth2.IssueInfo{
Issuer: "https://" + c.host,
Audience: c.host,
Subject: serviceAccountID,
ServiceAccount: &oauth2.ServiceAccount{
OrganizationID: organization.ID,
// TODO: allow the client to override this, but keep it capped to
// some server controlled value.
Duration: &c.options.defaultDuration,
},
}

tokens, err := c.oauth2.Issue(ctx, issueInfo)
if err != nil {
return nil, errors.OAuth2ServerError("unable to issue access token").WithError(err)
}

return tokens, nil
}

// generate takes an API request and generates a new Kubernetes resource for it, including
// a new access token.
func (c *Client) generate(ctx context.Context, organization *organizations.Meta, in *openapi.ServiceAccountWrite) (*unikornv1.ServiceAccount, error) {
userinfo, err := authorization.UserinfoFromContext(ctx)
if err != nil {
return nil, errors.OAuth2ServerError("userinfo is not set").WithError(err)
}

out := &unikornv1.ServiceAccount{
ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, organization.Namespace, userinfo.Sub).WithOrganization(organization.ID).Get(),
Spec: unikornv1.ServiceAccountSpec{
Tags: conversion.GenerateTagList(in.Metadata.Tags),
},
}

issueInfo := &oauth2.IssueInfo{
Issuer: "https://" + c.host,
Audience: c.host,
Subject: in.Metadata.Name,
Subject: out.Name,
ServiceAccount: &oauth2.ServiceAccount{
OrganizationID: organization.ID,
// TODO: allow the client to override this, but keep it capped to
Expand All @@ -157,16 +189,8 @@ func (c *Client) generate(ctx context.Context, organization *organizations.Meta,
return nil, errors.OAuth2ServerError("unable to issue access token").WithError(err)
}

out := &unikornv1.ServiceAccount{
ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, organization.Namespace, userinfo.Sub).WithOrganization(organization.ID).Get(),
Spec: unikornv1.ServiceAccountSpec{
Tags: conversion.GenerateTagList(in.Metadata.Tags),
AccessToken: tokens.AccessToken,
Expiry: &metav1.Time{
Time: tokens.Expiry,
},
},
}
out.Spec.Expiry = &metav1.Time{Time: tokens.Expiry}
out.Spec.AccessToken = tokens.AccessToken

return out, nil
}
Expand Down Expand Up @@ -199,27 +223,27 @@ func (c *Client) listGroups(ctx context.Context, organization *organizations.Met

// updateGroups takes a user name and a requested list of groups and adds to
// the groups it should be a member of and removes itself from groups it shouldn't.
func (c *Client) updateGroups(ctx context.Context, userName string, groupIDs *openapi.GroupIDs, groups *unikornv1.GroupList) error {
func (c *Client) updateGroups(ctx context.Context, serviceAccountID string, groupIDs *openapi.GroupIDs, groups *unikornv1.GroupList) error {
for i := range groups.Items {
current := &groups.Items[i]

updated := current.DeepCopy()

if groupIDs != nil && slices.Contains(*groupIDs, current.Name) {
// Add to a group where it should be a member but isn't.
if slices.Contains(current.Spec.Users, userName) {
if slices.Contains(current.Spec.ServiceAccountIDs, serviceAccountID) {
continue
}

updated.Spec.Users = append(updated.Spec.Users, userName)
updated.Spec.ServiceAccountIDs = append(updated.Spec.ServiceAccountIDs, serviceAccountID)
} else {
// Remove from any groups its a member of but shouldn't be.
if !slices.Contains(current.Spec.Users, userName) {
if !slices.Contains(current.Spec.ServiceAccountIDs, serviceAccountID) {
continue
}

updated.Spec.Users = slices.DeleteFunc(updated.Spec.Users, func(name string) bool {
return name == userName
updated.Spec.ServiceAccountIDs = slices.DeleteFunc(updated.Spec.ServiceAccountIDs, func(id string) bool {
return id == serviceAccountID
})
}

Expand Down Expand Up @@ -252,7 +276,7 @@ func (c *Client) Create(ctx context.Context, organizationID string, request *ope
return nil, err
}

if err := c.updateGroups(ctx, request.Metadata.Name, request.Spec.GroupIDs, groups); err != nil {
if err := c.updateGroups(ctx, resource.Name, request.Spec.GroupIDs, groups); err != nil {
return nil, err
}

Expand Down Expand Up @@ -318,22 +342,18 @@ func (c *Client) Update(ctx context.Context, organizationID, serviceAccountID st
return nil, err
}

// Name must be immutable as it's referred to by the access token and
// the group linkage.
if current.Labels[constants.NameLabel] != required.Labels[constants.NameLabel] {
return nil, errors.OAuth2InvalidRequest("service account name is immutable")
}

if err := conversion.UpdateObjectMetadata(required, current, nil, nil); err != nil {
return nil, errors.OAuth2ServerError("failed to merge metadata").WithError(err)
}

updated := current.DeepCopy()
updated.Labels = required.Labels
updated.Annotations = required.Annotations
updated.Spec = required.Spec

// Preserve the access token etc. across metadata updates.
updated.Spec = current.Spec
updated.Spec.Expiry = current.Spec.Expiry
updated.Spec.AccessToken = current.Spec.AccessToken

if err := c.client.Patch(ctx, updated, client.MergeFrom(current)); err != nil {
return nil, errors.OAuth2ServerError("failed to patch group").WithError(err)
Expand All @@ -344,7 +364,7 @@ func (c *Client) Update(ctx context.Context, organizationID, serviceAccountID st
return nil, err
}

if err := c.updateGroups(ctx, request.Metadata.Name, request.Spec.GroupIDs, groups); err != nil {
if err := c.updateGroups(ctx, current.Name, request.Spec.GroupIDs, groups); err != nil {
return nil, err
}

Expand All @@ -364,25 +384,14 @@ func (c *Client) Rotate(ctx context.Context, organizationID, serviceAccountID st
return nil, err
}

request := &openapi.ServiceAccountWrite{
Metadata: coreopenapi.ResourceWriteMetadata{
Name: current.Labels[constants.NameLabel],
},
}

required, err := c.generate(ctx, organization, request)
tokens, err := c.generateAccessToken(ctx, organization, serviceAccountID)
if err != nil {
return nil, err
}

if err := conversion.UpdateObjectMetadata(required, current, nil, nil); err != nil {
return nil, errors.OAuth2ServerError("failed to merge metadata").WithError(err)
}

updated := current.DeepCopy()
updated.Labels = required.Labels
updated.Annotations = required.Annotations
updated.Spec = required.Spec
updated.Spec.Expiry = &metav1.Time{Time: tokens.Expiry}
updated.Spec.AccessToken = tokens.AccessToken

if err := c.client.Patch(ctx, updated, client.MergeFrom(current)); err != nil {
return nil, errors.OAuth2ServerError("failed to patch group").WithError(err)
Expand Down Expand Up @@ -420,7 +429,7 @@ func (c *Client) Delete(ctx context.Context, organizationID, serviceAccountID st
return err
}

if err := c.updateGroups(ctx, resource.Labels[constants.NameLabel], nil, groups); err != nil {
if err := c.updateGroups(ctx, serviceAccountID, nil, groups); err != nil {
return err
}

Expand Down
Loading

0 comments on commit 0b056cd

Please sign in to comment.