Files
probo/pkg/server/api/connect/v1/identity_resolvers.go
Ludovic Vielle 26c5002932 Add identity-scoped OAuth token management
Let users create, list, and revoke manual bearer tokens from
/me/oauth-tokens, scoped to their identity rather than an
organization. Manual tokens store a null client_id and are
authorized with a self-manage IAM policy.

Wire Connect GraphQL on Identity (list, create, revoke), add
console UI with scoped create flow and credentials dialog, and
cover the flow in e2e tests. Fix list pagination ordering and
keep the Relay connection in sync after create.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
2026-06-18 20:08:49 +02:00

238 lines
7.8 KiB
Go

package connect_v1
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"fmt"
"github.com/99designs/gqlgen/graphql"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/authz"
"go.probo.inc/probo/pkg/server/api/connect/v1/schema"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/server/gqlutils/types/cursor"
)
// Profiles is the resolver for the profiles field.
func (r *identityResolver) Profiles(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ProfileOrderBy, filter *types.ProfileFilter) (*types.ProfileConnection, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileList, authz.WithSkipAssumptionCheck()); err != nil {
return nil, err
}
filters := coredata.NewMembershipProfileFilter(nil).WithMembership()
if filter != nil {
filters = coredata.NewMembershipProfileFilter(filter.ContractEnded).WithMembership()
if filter.State != nil {
filters.WithState(*filter.State)
}
}
if gqlutils.OnlyTotalCountSelected(ctx) {
return &types.ProfileConnection{
Resolver: r,
ParentID: obj.ID,
Filters: filters,
}, nil
}
pageOrderBy := page.OrderBy[coredata.MembershipProfileOrderField]{
Field: coredata.MembershipProfileOrderFieldFullName,
Direction: page.OrderDirectionAsc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.MembershipProfileOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.AccountService.ListProfilesForIdentity(ctx, obj.ID, cursor, filters)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list profiles", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewProfileConnection(page, r, obj.ID, filters), nil
}
// Sessions is the resolver for the sessions field.
func (r *identityResolver) Sessions(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SessionOrder) (*types.SessionConnection, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionSessionList); err != nil {
return nil, err
}
if gqlutils.OnlyTotalCountSelected(ctx) {
return &types.SessionConnection{
Resolver: r,
ParentID: obj.ID,
}, nil
}
pageOrderBy := page.OrderBy[coredata.SessionOrderField]{
Field: coredata.SessionOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.SessionOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.AccountService.ListSessions(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list sessions", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewSessionConnection(page, r, obj.ID), nil
}
// PersonalAPIKeys is the resolver for the personalAPIKeys field.
func (r *identityResolver) PersonalAPIKeys(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.PersonalAPIKeyConnection, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionPersonalAPIKeyList); err != nil {
return nil, err
}
if gqlutils.OnlyTotalCountSelected(ctx) {
return &types.PersonalAPIKeyConnection{
Resolver: r,
ParentID: obj.ID,
}, nil
}
pageOrderBy := page.OrderBy[coredata.PersonalAPIKeyOrderField]{
Field: coredata.PersonalAPIKeyOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.AccountService.ListPersonalAPIKeys(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list personal api keys", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewPersonalAPIKeyConnection(page, r, obj.ID), nil
}
// Oauth2AccessTokens is the resolver for the oauth2AccessTokens field.
func (r *identityResolver) Oauth2AccessTokens(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.OAuth2AccessTokenConnection, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionOAuth2AccessTokenList); err != nil {
return nil, err
}
if gqlutils.OnlyTotalCountSelected(ctx) {
return &types.OAuth2AccessTokenConnection{
Resolver: r,
ParentID: obj.ID,
}, nil
}
pageOrderBy := page.OrderBy[coredata.OAuth2AccessTokenOrderField]{
Field: coredata.OAuth2AccessTokenOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
tokenPage, err := r.iam.OAuth2ServerService.ListAccessTokensByIdentityID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list oauth2 access tokens", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewOAuth2AccessTokenConnection(tokenPage, r, obj.ID), nil
}
// InvitingOrganizations is the resolver for the invitingOrganizations field.
func (r *identityResolver) InvitingOrganizations(ctx context.Context, obj *types.Identity) ([]*types.Organization, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionInvitationList, authz.WithSkipAssumptionCheck()); err != nil {
return nil, err
}
organizations, err := r.iam.AccountService.ListInvitingOrganizations(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list inviting organizations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
result := make([]*types.Organization, len(organizations))
for i, organization := range organizations {
result[i] = types.NewOrganization(organization)
}
return result, nil
}
// SsoLoginURL is the resolver for the ssoLoginURL field.
func (r *identityResolver) SsoLoginURL(ctx context.Context, obj *types.Identity) (*string, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionIdentityGet); err != nil {
return nil, err
}
identity := authn.IdentityFromContext(ctx)
count, err := r.iam.AccountService.CountSAMLConfigurationsForEmail(ctx, identity.EmailAddress)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count SAML configurations for email", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if count != 1 {
if count == 0 {
return nil, graphql.ErrorOnPath(
ctx,
fmt.Errorf("no SAML configuration for email"),
)
}
return nil, graphql.ErrorOnPath(
ctx,
fmt.Errorf("multiple SSO configurations found for this domain. Please use your organization-specific SSO login URL"),
)
}
samlConfigs, err := r.iam.AccountService.ListSAMLConfigurationsForEmail(ctx, identity.EmailAddress)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list SAML configurations for email", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if len(samlConfigs) == 0 {
r.logger.ErrorCtx(ctx, "cannot find SAML config")
return nil, gqlutils.NotFoundf(ctx, "cannot find SAML config")
}
samlConfig := samlConfigs[0]
loginURL := r.SSOLoginURL(samlConfig.ID)
return &loginURL, nil
}
// Permission is the resolver for the permission field.
func (r *identityResolver) Permission(ctx context.Context, obj *types.Identity, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// Identity returns schema.IdentityResolver implementation.
func (r *Resolver) Identity() schema.IdentityResolver { return &identityResolver{r} }
type identityResolver struct{ *Resolver }