Rewrite identity and access management

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-12-03 19:23:15 +01:00
parent 4ed3f5a067
commit 74fc3b8cd1
201 changed files with 32895 additions and 23649 deletions

View File

@@ -21,14 +21,14 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/vektah/gqlparser/v2/ast"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/saferedirect"
"go.probo.inc/probo/pkg/securecookie"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
console_v1 "go.probo.inc/probo/pkg/server/api/console/v1"
mcp_v1 "go.probo.inc/probo/pkg/server/api/mcp/v1"
slack_v1 "go.probo.inc/probo/pkg/server/api/slack/v1"
@@ -38,39 +38,16 @@ import (
)
type (
ConsoleAuthConfig struct {
CookieName string
CookieDomain string
SessionDuration time.Duration
CookieSecret string
CookieSecure bool
}
TrustAuthConfig struct {
CookieName string
CookieDomain string
CookieDuration time.Duration
TokenDuration time.Duration
ReportURLDuration time.Duration
TokenSecret string
Scope string
TokenType string
CookieSecure bool
}
Config struct {
BaseURL *baseurl.BaseURL
AllowedOrigins []string
Probo *probo.Service
Auth *auth.Service
Authz *authz.Service
IAM *iam.Service
Trust *trust.Service
Slack *slack.Service
SAML *auth.SAMLService
ConsoleAuth ConsoleAuthConfig
TrustAuth TrustAuthConfig
MCPConfig MCPConfig
Cookie securecookie.Config
TokenSecret string
ConnectorRegistry *connector.ConnectorRegistry
SafeRedirect *saferedirect.SafeRedirect
CustomDomainCname string
Logger *log.Logger
}
@@ -82,26 +59,21 @@ type (
}
Server struct {
cfg Config
trustAPIHandler http.Handler
consoleAPIHandler http.Handler
mcpAPIHandler http.Handler
slackAPIHandler http.Handler
cfg Config
compliancePageHandler http.Handler
consoleHandler http.Handler
mcpHandler http.Handler
slackHandler http.Handler
connectHandler http.Handler
}
)
var (
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
ErrMissingAuthService = errors.New("server configuration requires a valid auth.Service instance")
ErrMissingAuthzService = errors.New("server configuration requires a valid authz.Service instance")
ErrMissingIAMService = errors.New("server configuration requires a valid iam.Service instance")
ErrMissingSlackService = errors.New("server configuration requires a valid slack.Service instance")
)
// GetConsoleSchema returns the GraphQL schema for the console API
func GetConsoleSchema() *ast.Schema {
return console_v1.GetSchema()
}
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
@@ -131,91 +103,53 @@ func NewServer(cfg Config) (*Server, error) {
return nil, ErrMissingProboService
}
if cfg.Auth == nil {
return nil, ErrMissingAuthService
}
if cfg.Authz == nil {
return nil, ErrMissingAuthzService
if cfg.IAM == nil {
return nil, ErrMissingIAMService
}
if cfg.Slack == nil {
return nil, ErrMissingSlackService
}
trustAPIHandler := trust_v1.NewMux(
cfg.Logger.Named("trust.v1"),
cfg.Auth,
cfg.Authz,
cfg.Trust,
console_v1.AuthConfig{
CookieName: cfg.ConsoleAuth.CookieName,
CookieDomain: cfg.ConsoleAuth.CookieDomain,
SessionDuration: cfg.ConsoleAuth.SessionDuration,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
CookieSecure: cfg.ConsoleAuth.CookieSecure,
},
trust_v1.TrustAuthConfig{
CookieName: cfg.TrustAuth.CookieName,
CookieDomain: cfg.TrustAuth.CookieDomain,
CookieDuration: cfg.TrustAuth.CookieDuration,
TokenDuration: cfg.TrustAuth.TokenDuration,
ReportURLDuration: cfg.TrustAuth.ReportURLDuration,
TokenSecret: cfg.TrustAuth.TokenSecret,
Scope: cfg.TrustAuth.Scope,
TokenType: cfg.TrustAuth.TokenType,
CookieSecure: cfg.TrustAuth.CookieSecure,
},
cfg.Slack,
)
consoleAPIHandler := console_v1.NewMux(
cfg.Logger.Named("console.v1"),
cfg.Probo,
cfg.Auth,
cfg.Authz,
console_v1.AuthConfig{
CookieName: cfg.ConsoleAuth.CookieName,
CookieDomain: cfg.ConsoleAuth.CookieDomain,
SessionDuration: cfg.ConsoleAuth.SessionDuration,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
CookieSecure: cfg.ConsoleAuth.CookieSecure,
},
cfg.ConnectorRegistry,
cfg.SafeRedirect,
cfg.CustomDomainCname,
cfg.SAML,
)
mcpAPIHandler := mcp_v1.NewMux(
cfg.Logger.Named("mcp.v1"),
cfg.Probo,
cfg.Auth,
cfg.Authz,
mcp_v1.Config{
Version: cfg.MCPConfig.Version,
RequestTimeout: cfg.MCPConfig.RequestTimeout,
MaxRequestSize: cfg.MCPConfig.MaxRequestSize,
},
)
slackAPIHandler := slack_v1.NewMux(
cfg.Logger.Named("slack.v1"),
cfg.Slack,
cfg.Trust,
)
return &Server{
cfg: cfg,
trustAPIHandler: trustAPIHandler,
consoleAPIHandler: consoleAPIHandler,
mcpAPIHandler: mcpAPIHandler,
slackAPIHandler: slackAPIHandler,
cfg: cfg,
compliancePageHandler: trust_v1.NewMux(
cfg.Logger.Named("trust.v1"),
cfg.IAM,
cfg.Trust,
cfg.Cookie,
),
consoleHandler: console_v1.NewMux(
cfg.Logger.Named("console.v1"),
cfg.Probo,
cfg.IAM,
cfg.Cookie,
cfg.TokenSecret,
cfg.ConnectorRegistry,
cfg.BaseURL,
cfg.CustomDomainCname,
),
mcpHandler: mcp_v1.NewMux(
cfg.Logger.Named("mcp.v1"),
cfg.Probo,
cfg.IAM,
),
slackHandler: slack_v1.NewMux(
cfg.Logger.Named("slack.v1"),
cfg.Slack,
cfg.Trust,
),
connectHandler: connect_v1.NewMux(
cfg.Logger.Named("connect.v1"),
cfg.IAM,
cfg.Cookie,
cfg.BaseURL,
),
}, nil
}
func (s *Server) TrustAPIHandler() http.Handler {
return s.trustAPIHandler
func (s *Server) CompliancePageHandler() http.Handler {
return s.compliancePageHandler
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -223,7 +157,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
AllowedOrigins: s.cfg.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
AllowedHeaders: []string{"content-type", "traceparent", "authorization"},
ExposedHeaders: []string{"x-Request-id"},
ExposedHeaders: []string{"x-request-id"},
AllowCredentials: true,
MaxAge: 600, // 10 minutes (chrome >= 76 maximum value c.f. https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/cors/preflight_result.cc;drc=52002151773d8cd9ffc5f557cd7cc880fddcae3e;l=36)
OptionsPassthrough: false,
@@ -243,10 +177,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
router.Use(cors.Handler(corsOpts))
router.Mount("/console/v1", s.consoleAPIHandler)
router.Mount("/trust/v1", s.trustAPIHandler)
router.Mount("/mcp/v1", s.mcpAPIHandler)
router.Mount("/slack/v1", s.slackAPIHandler)
router.Mount("/console/v1", s.consoleHandler)
router.Mount("/connect/v1", s.connectHandler)
router.Mount("/trust/v1", s.compliancePageHandler)
router.Mount("/mcp/v1", s.mcpHandler)
router.Mount("/slack/v1", s.slackHandler)
router.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
import (
"context"
"errors"
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securetoken"
)
var (
apiKeyContextKey = &ctxKey{name: "api_key"}
)
func APIKeyFromContext(ctx context.Context) *coredata.UserAPIKey {
apiKey, _ := ctx.Value(apiKeyContextKey).(*coredata.UserAPIKey)
return apiKey
}
func NewAPIKeyMiddleware(svc *iam.Service) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session := SessionFromContext(ctx)
if session != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("api key authentication cannot be used with session authentication"))
return
}
tokenValue, err := securetoken.Get(r, "")
if err != nil {
next.ServeHTTP(w, r)
return
}
keyID, err := gid.ParseGID(tokenValue)
if err != nil {
next.ServeHTTP(w, r)
return
}
apiKey, err := svc.APIKeyService.GetAPIKey(ctx, keyID)
if err != nil {
var errUserAPIKeyNotFound *iam.ErrUserAPIKeyNotFound
var errUserAPIKeyExpired *iam.ErrUserAPIKeyExpired
if errors.As(err, &errUserAPIKeyNotFound) || errors.As(err, &errUserAPIKeyExpired) {
next.ServeHTTP(w, r)
return
}
panic(fmt.Errorf("cannot get user API key: %w", err))
}
user, err := svc.AccountService.GetIdentity(ctx, apiKey.UserID)
if err != nil {
var errUserNotFound *iam.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
next.ServeHTTP(w, r)
return
}
panic(fmt.Errorf("cannot get user: %w", err))
}
ctx = context.WithValue(ctx, apiKeyContextKey, apiKey)
ctx = context.WithValue(ctx, identityContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
},
)
}
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
type (
ctxKey struct{ name string }
)

View File

@@ -0,0 +1,36 @@
schema: ["schema.graphql"]
exec:
filename: "schema/schema.go"
package: "schema"
model:
filename: "types/types.go"
package: "types"
resolver:
layout: "follow-schema"
dir: "."
package: "connect_v1"
filename_template: "v1_resolver.go"
autobind: []
call_argument_directives_with_null: true
directives:
mustBeAuthorized:
skip_runtime: false
models:
ID:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/gid.GIDScalar"
Datetime:
model:
- "github.com/99designs/gqlgen/graphql.Time"
CursorKey:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar"
EmailAddr:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar"

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
import (
"context"
"fmt"
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
"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"
)
var (
ErrForbidden = &gqlerror.Error{
Message: "You are not authorized to access this resource",
Extensions: map[string]any{
"code": "FORBIDDEN",
},
}
ErrUnauthorized = &gqlerror.Error{
Message: "You are not authorized to access this resource",
Extensions: map[string]any{
"code": "UNAUTHORIZED",
},
}
ErrAlreadyAuthenticated = &gqlerror.Error{
Message: "authentication not allowed for this resource/action",
Extensions: map[string]any{
"code": "ALREADY_AUTHENTICATED",
},
}
)
func SessionDirective(ctx context.Context, obj any, next graphql.Resolver, required types.SessionRequirement) (any, error) {
session := SessionFromContext(ctx)
switch required {
case types.SessionRequirementOptional:
case types.SessionRequirementPresent:
if session == nil {
return nil, ErrUnauthorized
}
case types.SessionRequirementNone:
if session != nil {
return nil, ErrAlreadyAuthenticated
}
}
return next(ctx)
}
func IsViewerDirective(ctx context.Context, obj any, next graphql.Resolver) (any, error) {
identity := UserFromContext(ctx)
resolvedIdentity, ok := obj.(*types.Identity)
if !ok {
panic(fmt.Errorf("@isViewer called on non-identity object: %T", obj))
}
if identity.ID != resolvedIdentity.ID {
return nil, ErrForbidden
}
return next(ctx)
}
func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, cookieConfig securecookie.Config) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
iam: svc,
cookieConfig: cookieConfig,
},
Directives: schema.DirectiveRoot{
Session: SessionDirective,
IsViewer: IsViewerDirective,
},
}
es := schema.NewExecutableSchema(config)
gqlh := gqlutils.NewHandler(es, logger)
return gqlh
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
import (
"context"
"net/http"
)
var (
httpResponseWriterKey = &ctxKey{name: "http_response_writer"}
httpRequestKey = &ctxKey{name: "http_request"}
)
func HTTPContextMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx := WithHTTPContext(r.Context(), w, r)
next.ServeHTTP(w, r.WithContext(ctx))
},
)
}
func WithHTTPContext(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context {
ctx = context.WithValue(ctx, httpResponseWriterKey, w)
ctx = context.WithValue(ctx, httpRequestKey, r)
return ctx
}
func HTTPResponseWriterFromContext(ctx context.Context) http.ResponseWriter {
return ctx.Value(httpResponseWriterKey).(http.ResponseWriter)
}
func HTTPRequestFromContext(ctx context.Context) *http.Request {
return ctx.Value(httpRequestKey).(*http.Request)
}

View File

@@ -0,0 +1,66 @@
//go:generate go run github.com/99designs/gqlgen generate
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
import (
"time"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
)
type (
Resolver struct {
iam *iam.Service
cookieConfig securecookie.Config
}
)
func (r *Resolver) sessionCookieConfig(maxAge time.Duration) securecookie.Config {
return securecookie.Config{
Name: r.cookieConfig.Name,
Secret: r.cookieConfig.Secret,
Secure: r.cookieConfig.Secure,
HTTPOnly: r.cookieConfig.HTTPOnly,
SameSite: r.cookieConfig.SameSite,
Path: r.cookieConfig.Path,
Domain: r.cookieConfig.Domain,
MaxAge: int(maxAge.Seconds()),
}
}
func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Config, baseURL *baseurl.BaseURL) *chi.Mux {
r := chi.NewMux()
r.Use(HTTPContextMiddleware)
sessionMiddleware := NewSessionMiddleware(svc, cookieConfig)
graphqlHandler := NewGraphQLHandler(svc, logger, cookieConfig)
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL)
router := r.With(sessionMiddleware)
router.Handle("/graphql", graphqlHandler)
router.Get("/saml/2.0/metadata", samlHandler.MetadataHandler)
router.Post("/saml/2.0/consume", samlHandler.ConsumeHandler)
router.Get("/saml/2.0/{samlConfigID}", samlHandler.LoginHandler)
return r
}

View File

@@ -0,0 +1,96 @@
package connect_v1
import (
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
)
type SAMLHandler struct {
iam *iam.Service
cookieConfig securecookie.Config
baseURL *baseurl.BaseURL
}
func NewSAMLHandler(iam *iam.Service, cookieConfig securecookie.Config, baseURL *baseurl.BaseURL) *SAMLHandler {
return &SAMLHandler{iam: iam, cookieConfig: cookieConfig, baseURL: baseURL}
}
func (h *SAMLHandler) MetadataHandler(w http.ResponseWriter, r *http.Request) {
metadataXML, err := h.iam.SAMLService.GenerateSpMetadata()
if err != nil {
panic(fmt.Errorf("cannot generate metadata: %w", err))
}
w.Header().Set("Content-Type", "application/samlmetadata+xml")
w.WriteHeader(http.StatusOK)
w.Write(metadataXML)
}
func (h *SAMLHandler) ConsumeHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := r.ParseForm()
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("cannot parse form"))
return
}
samlResponse := r.FormValue("SAMLResponse")
relayState := r.FormValue("RelayState")
configID, err := gid.ParseGID(relayState)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid relay state"))
return
}
user, membership, err := h.iam.SAMLService.HandleAssertion(ctx, samlResponse, configID)
if err != nil {
httpserver.RenderError(w, http.StatusUnauthorized, err)
return
}
session := SessionFromContext(ctx)
if session == nil {
h.iam.AuthService.OpenSessionWithoutPassword(ctx, user.ID, membership.OrganizationID)
}
// TODO open or update the organization session
securecookie.Set(w, h.cookieConfig, session.ID.String())
redirectURL := h.baseURL.WithPath("/organizations/" + membership.OrganizationID.String()).MustString()
http.Redirect(w, r, redirectURL, http.StatusFound)
}
func (h *SAMLHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
samlConfigIDParam := chi.URLParam(r, "samlConfigID")
if samlConfigIDParam == "" {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("missing SAML config ID"))
return
}
samlConfigID, err := gid.ParseGID(samlConfigIDParam)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid SAML config ID"))
return
}
url, err := h.iam.SAMLService.InitiateLogin(ctx, samlConfigID)
if err != nil {
panic(fmt.Errorf("cannot initiate SAML login: %w", err))
}
http.Redirect(w, r, url.String(), http.StatusFound)
}

View File

@@ -0,0 +1,769 @@
directive @goField(
forceResolver: Boolean
name: String
omittable: Boolean
) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
directive @goModel(
model: String
models: [String!]
) on OBJECT | INPUT_OBJECT | SCALAR | ENUM | INTERFACE | UNION
directive @goEnum(value: String) on ENUM_VALUE
directive @session(required: SessionRequirement!) on FIELD_DEFINITION
directive @isViewer on FIELD_DEFINITION
scalar CursorKey
scalar Datetime
scalar Upload
scalar EmailAddr
enum SessionRequirement {
PRESENT
NONE
OPTIONAL
}
enum OrderDirection
@goModel(model: "go.probo.inc/probo/pkg/page.OrderDirection") {
ASC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionAsc")
DESC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionDesc")
}
enum SessionOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.SessionOrderField") {
CREATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SessionOrderFieldCreatedAt")
EXPIRED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SessionOrderFieldExpiredAt")
UPDATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SessionOrderFieldUpdatedAt")
}
input SessionOrder {
direction: OrderDirection!
field: SessionOrderField!
}
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node @session(required: PRESENT)
viewer: Identity @session(required: PRESENT)
checkSSOAvailability(email: String!): SSOAvailability!
@session(required: NONE)
}
type Mutation {
signIn(input: SignInInput!): SignInPayload! @session(required: NONE)
signUp(input: SignUpInput!): SignUpPayload! @session(required: NONE)
signOut: SignOutPayload! @session(required: PRESENT)
signUpFromInvitation(
input: SignUpFromInvitationInput!
): SignUpFromInvitationPayload! @session(required: NONE)
forgotPassword(input: ForgotPasswordInput!): ForgotPasswordPayload!
@session(required: NONE)
resetPassword(input: ResetPasswordInput!): ResetPasswordPayload!
@session(required: NONE)
verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload!
@session(required: OPTIONAL)
changePassword(input: ChangePasswordInput!): ChangePasswordPayload!
@session(required: PRESENT)
changeEmail(input: ChangeEmailInput!): ChangeEmailPayload!
@session(required: PRESENT)
updateIdentityProfile(
input: UpdateIdentityProfileInput!
): UpdateIdentityProfilePayload! @session(required: PRESENT)
revokeSession(input: RevokeSessionInput!): RevokeSessionPayload!
@session(required: PRESENT)
revokeAllSessions: RevokeAllSessionsPayload! @session(required: PRESENT)
createPersonalAPIKey(
input: CreatePersonalAPIKeyInput!
): CreatePersonalAPIKeyPayload! @session(required: PRESENT)
updatePersonalAPIKey(
input: UpdatePersonalAPIKeyInput!
): UpdatePersonalAPIKeyPayload! @session(required: PRESENT)
revokePersonalAPIKey(
input: RevokePersonalAPIKeyInput!
): RevokePersonalAPIKeyPayload! @session(required: PRESENT)
createOrganization(
input: CreateOrganizationInput!
): CreateOrganizationPayload! @session(required: PRESENT)
updateOrganization(
input: UpdateOrganizationInput!
): UpdateOrganizationPayload! @session(required: PRESENT)
deleteOrganization(
input: DeleteOrganizationInput!
): DeleteOrganizationPayload! @session(required: PRESENT)
inviteMember(input: InviteMemberInput!): InviteMemberPayload!
@session(required: PRESENT)
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload!
@session(required: PRESENT)
removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
@session(required: PRESENT)
acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload!
@session(required: PRESENT)
createSAMLConfiguration(
input: CreateSAMLConfigurationInput!
): CreateSAMLConfigurationPayload! @session(required: PRESENT)
updateSAMLConfiguration(
input: UpdateSAMLConfigurationInput!
): UpdateSAMLConfigurationPayload! @session(required: PRESENT)
deleteSAMLConfiguration(
input: DeleteSAMLConfigurationInput!
): DeleteSAMLConfigurationPayload! @session(required: PRESENT)
}
type Identity implements Node {
id: ID!
email: EmailAddr!
emailVerified: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
memberships(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MembershipConnection! @goField(forceResolver: true) @isViewer
pendingInvitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
): InvitationConnection! @goField(forceResolver: true) @isViewer
sessions(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: SessionOrder
): SessionConnection! @goField(forceResolver: true) @isViewer
personalAPIKeys(
first: Int
after: CursorKey
last: Int
before: CursorKey
): PersonalAPIKeyConnection! @goField(forceResolver: true) @isViewer
profileFor(organizationId: ID!): IdentityProfile @isViewer
}
type IdentityProfile implements Node {
id: ID!
displayName: String!
firstName: String
lastName: String
jobTitle: String
department: String
phoneNumber: String
avatarUrl: String
manager: IdentityProfile
timezone: String
locale: String
customAttributes: [CustomAttribute!]!
provisionedBy: ProvisioningSource!
externalId: String
identity: Identity!
organization: Organization!
createdAt: Datetime!
updatedAt: Datetime!
}
type CustomAttribute {
key: String!
value: String!
}
type Organization implements Node {
id: ID!
name: String!
logoUrl: String @goField(forceResolver: true)
horizontalLogoUrl: String @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
members(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MembershipConnection! @goField(forceResolver: true)
invitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
status: InvitationStatus
): InvitationConnection! @goField(forceResolver: true)
samlConfigurations(
first: Int
after: CursorKey
last: Int
before: CursorKey
): SAMLConfigurationConnection! @goField(forceResolver: true)
availableApplications: [Application!]!
}
type Membership implements Node {
id: ID!
createdAt: Datetime!
profile: IdentityProfile!
identity: Identity! @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
permissions: [Permission!]!
provisionedBy: ProvisioningSource!
active: Boolean!
lastSyncedAt: Datetime
}
type Invitation implements Node {
id: ID!
email: EmailAddr!
expiresAt: Datetime!
acceptedAt: Datetime
createdAt: Datetime!
status: InvitationStatus!
}
type InvitationProfile {
displayName: String!
firstName: String
lastName: String
jobTitle: String
department: String
}
type Session implements Node {
id: ID!
ipAddress: String!
userAgent: String!
updatedAt: Datetime!
createdAt: Datetime!
expiresAt: Datetime!
}
type PersonalAPIKey implements Node {
id: ID!
name: String!
lastUsedAt: Datetime
expiresAt: Datetime!
createdAt: Datetime!
scopes: [TokenScope!]!
organizations: [Organization!]!
}
type Permission implements Node {
id: ID!
createdAt: Datetime!
application: Application!
accessLevel: AccessLevel!
organization: Organization!
principalType: PrincipalType!
principalId: ID!
}
type PermissionGrant {
application: Application!
accessLevel: AccessLevel!
}
type Application {
id: ApplicationId!
name: String!
description: String!
availableAccessLevels: [AccessLevel!]!
}
type SessionPolicy {
maxSessionDurationHours: Int!
idleTimeoutMinutes: Int!
maxConcurrentSessions: Int
requireReauthForSensitiveActions: Boolean!
}
type SAMLConfiguration implements Node {
id: ID!
emailDomain: String!
enabled: Boolean!
enforcementPolicy: SAMLEnforcementPolicy!
domainVerified: Boolean!
domainVerifiedAt: Datetime
domainVerificationToken: String
idpEntityId: String!
idpSsoUrl: String!
idpCertificate: String!
autoSignupEnabled: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
spMetadataUrl: String!
testLoginUrl: String!
attributeMappings: SAMLAttributeMappings!
defaultPermissions: [PermissionGrant!]!
}
type SAMLAttributeMappings {
email: String!
firstName: String!
lastName: String!
role: String!
}
type SSOAvailability {
available: Boolean!
samlConfigId: ID
organizationId: ID
}
enum ApplicationId {
CONSOLE
COMPLIANCE
RISK
VENDOR
DOCUMENTS
TRUST_CENTER
SETTINGS
API
}
enum AccessLevel {
READ
WRITE
ADMIN
}
enum PrincipalType {
IDENTITY
SERVICE_ACCOUNT
}
enum InvitationStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.InvitationStatus") {
PENDING
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusPending")
ACCEPTED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusAccepted")
EXPIRED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusExpired")
}
enum SAMLEnforcementPolicy
@goModel(model: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicy") {
OFF @goEnum(value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyOff")
OPTIONAL
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyOptional"
)
REQUIRED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyRequired"
)
}
enum AuthMethod {
PASSWORD
SAML
RECOVERY_CODE
}
enum TokenScope {
READ_ORGANIZATION
WRITE_ORGANIZATION
READ_COMPLIANCE
WRITE_COMPLIANCE
READ_RISK
WRITE_RISK
READ_VENDOR
WRITE_VENDOR
READ_DOCUMENTS
WRITE_DOCUMENTS
READ_TRUST_CENTER
WRITE_TRUST_CENTER
ADMIN
}
enum ProvisioningSource {
MANUAL
INVITATION
SAML
}
type MembershipConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.MembershipConnection"
) {
edges: [MembershipEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type MembershipEdge {
node: Membership!
cursor: CursorKey!
}
type InvitationConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.InvitationConnection"
) {
edges: [InvitationEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type InvitationEdge {
node: Invitation!
cursor: CursorKey!
}
type SessionConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.SessionConnection"
) {
edges: [SessionEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type SessionEdge {
node: Session!
cursor: CursorKey!
}
type PersonalAPIKeyConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.PersonalAPIKeyConnection"
) {
edges: [PersonalAPIKeyEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type PersonalAPIKeyEdge {
node: PersonalAPIKey!
cursor: CursorKey!
}
type SAMLConfigurationConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.SAMLConfigurationConnection"
) {
edges: [SAMLConfigurationEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type SAMLConfigurationEdge {
node: SAMLConfiguration!
cursor: CursorKey!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: CursorKey
endCursor: CursorKey
}
input SignInInput {
email: EmailAddr!
password: String!
}
input SignUpInput {
email: EmailAddr!
password: String!
fullName: String!
}
input SignUpFromInvitationInput {
token: String!
password: String!
}
input ForgotPasswordInput {
email: EmailAddr!
}
input ResetPasswordInput {
token: String!
password: String!
}
input VerifyEmailInput {
token: String!
}
input ChangePasswordInput {
currentPassword: String!
newPassword: String!
}
input ChangeEmailInput {
newEmail: EmailAddr!
password: String!
}
input DeactivateAccountInput {
password: String!
}
input DeleteAccountInput {
password: String!
confirmation: String!
}
input UpdateIdentityProfileInput {
membershipId: ID!
displayName: String
firstName: String
lastName: String
jobTitle: String
department: String
phoneNumber: String
timezone: String
locale: String
}
input RevokeSessionInput {
sessionId: ID!
}
input CreatePersonalAPIKeyInput {
name: String!
expiresAt: Datetime!
organizationIds: [ID!]!
}
input UpdatePersonalAPIKeyInput {
tokenId: ID!
name: String
description: String
}
input RevokePersonalAPIKeyInput {
tokenId: ID!
}
input CreateOrganizationInput {
name: String!
logoFile: Upload
horizontalLogoFile: Upload
}
input UpdateOrganizationInput {
organizationId: ID!
name: String
logoFile: Upload @goField(omittable: true)
horizontalLogoFile: Upload @goField(omittable: true)
}
input DeleteOrganizationInput {
organizationId: ID!
}
input SessionPolicyInput {
maxSessionDurationHours: Int
idleTimeoutMinutes: Int
maxConcurrentSessions: Int
requireReauthForSensitiveActions: Boolean
}
input AddIPAllowlistEntryInput {
organizationId: ID!
cidr: String!
description: String
}
input RemoveIPAllowlistEntryInput {
entryId: ID!
}
input InviteMemberInput {
organizationId: ID!
email: EmailAddr!
fullName: String!
}
input RemoveMemberInput {
organizationId: ID!
membershipId: ID!
}
input InvitationProfileInput {
displayName: String!
firstName: String
lastName: String
jobTitle: String
department: String
}
input AcceptInvitationInput {
invitationId: ID!
}
input DeleteInvitationInput {
organizationId: ID!
invitationId: ID!
}
input CreateSAMLConfigurationInput {
organizationId: ID!
emailDomain: String!
idpEntityId: String!
idpSsoUrl: String!
idpCertificate: String!
autoSignupEnabled: Boolean!
attributeMappings: SAMLAttributeMappingsInput
}
input SAMLAttributeMappingsInput {
email: String
firstName: String
lastName: String
role: String
}
input UpdateSAMLConfigurationInput {
organizationId: ID!
samlConfigurationId: ID!
idpEntityId: String
idpSsoUrl: String
idpCertificate: String
autoSignupEnabled: Boolean
enforcementPolicy: SAMLEnforcementPolicy
attributeMappings: SAMLAttributeMappingsInput
}
input DeleteSAMLConfigurationInput {
organizationId: ID!
samlConfigurationId: ID!
}
type SignInPayload {
identity: Identity
}
type SignUpPayload {
identity: Identity
}
type SignOutPayload {
success: Boolean!
}
type SignUpFromInvitationPayload {
identity: Identity
}
type ForgotPasswordPayload {
success: Boolean!
}
type ResetPasswordPayload {
success: Boolean!
}
type VerifyEmailPayload {
success: Boolean!
}
type ChangePasswordPayload {
success: Boolean!
}
type ChangeEmailPayload {
success: Boolean!
}
type DeactivateAccountPayload {
success: Boolean!
}
type DeleteAccountPayload {
success: Boolean!
}
type UpdateIdentityProfilePayload {
profile: IdentityProfile
}
type RevokeSessionPayload {
success: Boolean!
}
type RevokeAllSessionsPayload {
revokedCount: Int!
}
type CreatePersonalAPIKeyPayload {
personalAPIKeyEdge: PersonalAPIKeyEdge!
token: String!
}
type UpdatePersonalAPIKeyPayload {
personalAPIKey: PersonalAPIKey
}
type RevokePersonalAPIKeyPayload {
success: Boolean!
}
type CreateOrganizationPayload {
organization: Organization
membershipEdge: MembershipEdge!
}
type UpdateOrganizationPayload {
organization: Organization
}
type DeleteOrganizationPayload {
deletedOrganizationId: ID!
}
type InviteMemberPayload {
invitationEdge: InvitationEdge!
}
type RemoveMemberPayload {
deletedMembershipId: ID!
}
type AcceptInvitationPayload {
membershipEdge: MembershipEdge!
}
type DeleteInvitationPayload {
deletedInvitationId: ID!
}
type CreateSAMLConfigurationPayload {
samlConfigurationEdge: SAMLConfigurationEdge!
}
type UpdateSAMLConfigurationPayload {
samlConfiguration: SAMLConfiguration
}
type DeleteSAMLConfigurationPayload {
deletedSamlConfigurationId: ID!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
)
var (
identityContextKey = &ctxKey{name: "identity"}
sessionContextKey = &ctxKey{name: "session"}
)
func SessionFromContext(ctx context.Context) *coredata.Session {
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
return session
}
func UserFromContext(ctx context.Context) *coredata.User {
user, _ := ctx.Value(identityContextKey).(*coredata.User)
return user
}
func NewSessionMiddleware(svc *iam.Service, cookieConfig securecookie.Config) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := APIKeyFromContext(ctx)
if apiKey != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("session authentication cannot be used with API key authentication"))
return
}
cookieValue, err := securecookie.Get(r, cookieConfig)
if err != nil {
next.ServeHTTP(w, r)
return
}
sessionID, err := gid.ParseGID(cookieValue)
if err != nil {
securecookie.Clear(w, cookieConfig)
next.ServeHTTP(w, r)
return
}
session, err := svc.SessionService.GetSession(ctx, sessionID)
if err != nil {
var errSessionNotFound *iam.ErrSessionNotFound
var errSessionExpired *iam.ErrSessionExpired
if errors.As(err, &errSessionNotFound) || errors.As(err, &errSessionExpired) {
securecookie.Clear(w, cookieConfig)
next.ServeHTTP(w, r)
return
}
panic(fmt.Errorf("cannot get session: %w", err))
}
user, err := svc.AccountService.GetIdentity(ctx, session.UserID)
if err != nil {
var errUserNotFound *iam.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
securecookie.Clear(w, cookieConfig)
next.ServeHTTP(w, r)
return
}
panic(fmt.Errorf("cannot get user: %w", err))
}
userAgent := r.UserAgent()
// TODO: will work well when no layer 7 proxy is in front of the server
var ipAddress net.IP
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
ipAddress = net.ParseIP(host)
} else {
ipAddress = net.ParseIP(r.RemoteAddr)
}
err = svc.SessionService.UpdateSessionInfo(ctx, session.ID, userAgent, ipAddress)
if err != nil {
panic(fmt.Errorf("cannot update session info: %w", err))
}
ctx = context.WithValue(ctx, sessionContextKey, session)
ctx = context.WithValue(ctx, identityContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
err = svc.SessionService.UpdateSessionData(ctx, session.ID, session.Data)
if err != nil {
panic(fmt.Errorf("cannot update session data: %w", err))
}
},
)
}
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import "go.probo.inc/probo/pkg/coredata"
func NewIdentity(identity *coredata.User) *Identity {
return &Identity{
ID: identity.ID,
Email: identity.EmailAddress,
EmailVerified: identity.EmailAddressVerified,
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
}
}

View File

@@ -21,14 +21,16 @@ import (
)
type (
InvitationOrderBy OrderBy[coredata.InvitationOrderField]
InvitationConnection struct {
TotalCount int `json:"totalCount"`
Edges []*InvitationEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int
Edges []*InvitationEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
Filter *InvitationFilter
Filters *coredata.InvitationFilter
}
)
@@ -36,39 +38,37 @@ func NewInvitationConnection(
p *page.Page[*coredata.Invitation, coredata.InvitationOrderField],
resolver any,
parentID gid.GID,
filter *InvitationFilter,
filters *coredata.InvitationFilter,
) *InvitationConnection {
var edges = make([]*InvitationEdge, len(p.Data))
for i := range edges {
edges[i] = NewInvitationEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges := make([]*InvitationEdge, len(p.Data))
for i, invitation := range p.Data {
edges[i] = NewInvitationEdge(invitation, p.Cursor.OrderBy.Field)
}
return &InvitationConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
PageInfo: *NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
Filter: filter,
Filters: filters,
}
}
func NewInvitationEdge(invitation *coredata.Invitation, orderBy coredata.InvitationOrderField) *InvitationEdge {
func NewInvitationEdge(invitation *coredata.Invitation, orderField coredata.InvitationOrderField) *InvitationEdge {
return &InvitationEdge{
Cursor: invitation.CursorKey(orderBy),
Node: NewInvitation(invitation),
Cursor: invitation.CursorKey(orderField),
}
}
func NewInvitation(i *coredata.Invitation) *Invitation {
func NewInvitation(invitation *coredata.Invitation) *Invitation {
return &Invitation{
ID: i.ID,
Email: i.Email,
FullName: i.FullName,
Role: i.Role,
Status: i.Status,
ExpiresAt: i.ExpiresAt,
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
ID: invitation.ID,
Email: invitation.Email,
ExpiresAt: invitation.ExpiresAt,
AcceptedAt: invitation.AcceptedAt,
CreatedAt: invitation.CreatedAt,
Status: invitation.Status,
}
}

View File

@@ -21,16 +21,16 @@ import (
)
type (
MembershipOrderBy OrderBy[coredata.MembershipOrderField]
MembershipConnection struct {
TotalCount int `json:"totalCount"`
Edges []*MembershipEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int
Edges []*MembershipEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
MembershipOrderBy OrderBy[coredata.MembershipOrderField]
)
func NewMembershipConnection(
@@ -38,36 +38,34 @@ func NewMembershipConnection(
resolver any,
parentID gid.GID,
) *MembershipConnection {
var edges = make([]*MembershipEdge, len(p.Data))
for i := range edges {
edges[i] = NewMembershipEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges := make([]*MembershipEdge, len(p.Data))
for i, membership := range p.Data {
edges[i] = NewMembershipEdge(membership, p.Cursor.OrderBy.Field)
}
return &MembershipConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
PageInfo: *NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
}
}
func NewMembershipEdge(membership *coredata.Membership, orderBy coredata.MembershipOrderField) *MembershipEdge {
func NewMembershipEdge(membership *coredata.Membership, orderField coredata.MembershipOrderField) *MembershipEdge {
return &MembershipEdge{
Cursor: membership.CursorKey(orderBy),
Node: NewMembership(membership),
Cursor: membership.CursorKey(orderField),
}
}
func NewMembership(m *coredata.Membership) *Membership {
func NewMembership(membership *coredata.Membership) *Membership {
return &Membership{
ID: m.ID,
UserID: m.UserID,
OrganizationID: m.OrganizationID,
Role: m.Role,
FullName: m.FullName,
EmailAddress: m.EmailAddress,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
ID: membership.ID,
CreatedAt: membership.CreatedAt,
// Permissions: membership.Permissions,
// ProvisionedBy: membership.ProvisionedBy,
// Active: membership.Active,
// LastSyncedAt: membership.LastSyncedAt,
}
}

View File

@@ -12,24 +12,13 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mcp_v1
package types
import (
"time"
)
import "go.probo.inc/probo/pkg/page"
type (
Config struct {
Version string
RequestTimeout time.Duration
MaxRequestSize int64
OrderBy[T page.OrderField] struct {
Field T
Direction page.OrderDirection
}
)
func DefaultConfig() Config {
return Config{
Version: "1.0.0",
RequestTimeout: 30 * time.Second,
MaxRequestSize: 10 * 1024 * 1024, // 10MB
}
}

View File

@@ -12,27 +12,21 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
package types
import (
"fmt"
"net/http"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/coredata"
)
// SAMLMetadataHandler returns an HTTP handler that serves the SAML Service Provider metadata XML
// Uses global SP certificate configured at service startup
func SAMLMetadataHandler(samlSvc *authsvc.SAMLService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
metadataXML, err := samlSvc.GenerateMetadata()
if err != nil {
http.Error(w, fmt.Sprintf("cannot generate metadata: %v", err), http.StatusInternalServerError)
return
}
type (
OrganizationOrderBy OrderBy[coredata.OrganizationOrderField]
)
w.Header().Set("Content-Type", "application/samlmetadata+xml")
w.WriteHeader(http.StatusOK)
w.Write(metadataXML)
func NewOrganization(organization *coredata.Organization) *Organization {
return &Organization{
ID: organization.ID,
Name: organization.Name,
CreatedAt: organization.CreatedAt,
UpdatedAt: organization.UpdatedAt,
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/server/gqlutils/types/pageinfo"
)
func NewPageInfo[T page.Paginable[O], O page.OrderField](p *page.Page[T, O]) *PageInfo {
data := pageinfo.NewPageInfo(p)
return &PageInfo{
HasNextPage: data.HasNextPage,
HasPreviousPage: data.HasPreviousPage,
StartCursor: data.StartCursor,
EndCursor: data.EndCursor,
}
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
PersonalAPIKeyOrderBy OrderBy[coredata.UserAPIKeyOrderField]
PersonalAPIKeyConnection struct {
TotalCount int
Edges []*PersonalAPIKeyEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewPersonalAPIKeyConnection(
p *page.Page[*coredata.UserAPIKey, coredata.UserAPIKeyOrderField],
resolver any,
parentID gid.GID,
) *PersonalAPIKeyConnection {
edges := make([]*PersonalAPIKeyEdge, len(p.Data))
for i, personalAPIKey := range p.Data {
edges[i] = NewPersonalAPIKeyEdge(personalAPIKey, p.Cursor.OrderBy.Field)
}
return &PersonalAPIKeyConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
}
}
func NewPersonalAPIKeyEdge(personalAPIKey *coredata.UserAPIKey, orderField coredata.UserAPIKeyOrderField) *PersonalAPIKeyEdge {
return &PersonalAPIKeyEdge{
Node: NewPersonalAPIKey(personalAPIKey),
Cursor: personalAPIKey.CursorKey(orderField),
}
}
func NewPersonalAPIKey(personalAPIKey *coredata.UserAPIKey) *PersonalAPIKey {
return &PersonalAPIKey{
ID: personalAPIKey.ID,
Name: personalAPIKey.Name,
ExpiresAt: personalAPIKey.ExpiresAt,
CreatedAt: personalAPIKey.CreatedAt,
}
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
SAMLConfigurationOrderBy OrderBy[coredata.SAMLConfigurationOrderField]
SAMLConfigurationConnection struct {
TotalCount int
Edges []*SAMLConfigurationEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewSAMLConfigurationConnection(
p *page.Page[*coredata.SAMLConfiguration, coredata.SAMLConfigurationOrderField],
resolver any,
parentID gid.GID,
) *SAMLConfigurationConnection {
edges := make([]*SAMLConfigurationEdge, len(p.Data))
for i, samlConfiguration := range p.Data {
edges[i] = NewSAMLConfigurationEdge(samlConfiguration, p.Cursor.OrderBy.Field)
}
return &SAMLConfigurationConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
}
}
func NewSAMLConfigurationEdge(samlConfiguration *coredata.SAMLConfiguration, orderField coredata.SAMLConfigurationOrderField) *SAMLConfigurationEdge {
return &SAMLConfigurationEdge{
Node: NewSAMLConfiguration(samlConfiguration),
Cursor: samlConfiguration.CursorKey(orderField),
}
}
func NewSAMLConfiguration(samlConfiguration *coredata.SAMLConfiguration) *SAMLConfiguration {
return &SAMLConfiguration{
ID: samlConfiguration.ID,
EmailDomain: samlConfiguration.EmailDomain,
EnforcementPolicy: samlConfiguration.EnforcementPolicy,
DomainVerified: samlConfiguration.DomainVerified,
DomainVerifiedAt: samlConfiguration.DomainVerifiedAt,
DomainVerificationToken: samlConfiguration.DomainVerificationToken,
IdpEntityID: samlConfiguration.IdPEntityID,
IdpSsoURL: samlConfiguration.IdPSsoURL,
IdpCertificate: samlConfiguration.IdPCertificate,
CreatedAt: samlConfiguration.CreatedAt,
UpdatedAt: samlConfiguration.UpdatedAt,
AttributeMappings: &SAMLAttributeMappings{
Email: samlConfiguration.AttributeEmail,
FirstName: samlConfiguration.AttributeFirstname,
LastName: samlConfiguration.AttributeLastname,
Role: samlConfiguration.AttributeRole,
},
}
}

View File

@@ -21,50 +21,51 @@ import (
)
type (
UserConnection struct {
TotalCount int `json:"totalCount"`
Edges []*UserEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
SessionOrderBy OrderBy[coredata.SessionOrderField]
SessionConnection struct {
TotalCount int
Edges []*SessionEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
UserOrderBy OrderBy[coredata.UserOrderField]
)
func NewUserConnection(
p *page.Page[*coredata.User, coredata.UserOrderField],
func NewSessionConnection(
p *page.Page[*coredata.Session, coredata.SessionOrderField],
resolver any,
parentID gid.GID,
) *UserConnection {
var edges = make([]*UserEdge, len(p.Data))
for i := range edges {
edges[i] = NewUserEdge(p.Data[i], p.Cursor.OrderBy.Field)
) *SessionConnection {
edges := make([]*SessionEdge, len(p.Data))
for i, session := range p.Data {
edges[i] = NewSessionEdge(session, p.Cursor.OrderBy.Field)
}
return &UserConnection{
return &SessionConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
PageInfo: *NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
}
}
func NewUserEdge(user *coredata.User, orderBy coredata.UserOrderField) *UserEdge {
return &UserEdge{
Cursor: user.CursorKey(orderBy),
Node: NewUser(user),
func NewSessionEdge(session *coredata.Session, orderField coredata.SessionOrderField) *SessionEdge {
return &SessionEdge{
Node: NewSession(session),
Cursor: session.CursorKey(orderField),
}
}
func NewUser(u *coredata.User) *User {
return &User{
ID: u.ID,
Email: u.EmailAddress,
FullName: u.FullName,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
func NewSession(session *coredata.Session) *Session {
return &Session{
ID: session.ID,
IPAddress: session.IPAddress.String(),
UserAgent: session.UserAgent,
UpdatedAt: session.UpdatedAt,
CreatedAt: session.CreatedAt,
ExpiresAt: session.ExpiredAt,
}
}

View File

@@ -0,0 +1,966 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package types
import (
"bytes"
"fmt"
"io"
"strconv"
"time"
"github.com/99designs/gqlgen/graphql"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
)
type Node interface {
IsNode()
GetID() gid.GID
}
type AcceptInvitationInput struct {
InvitationID gid.GID `json:"invitationId"`
}
type AcceptInvitationPayload struct {
MembershipEdge *MembershipEdge `json:"membershipEdge"`
}
type AddIPAllowlistEntryInput struct {
OrganizationID gid.GID `json:"organizationId"`
Cidr string `json:"cidr"`
Description *string `json:"description,omitempty"`
}
type Application struct {
ID ApplicationID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
AvailableAccessLevels []AccessLevel `json:"availableAccessLevels"`
}
type ChangeEmailInput struct {
NewEmail mail.Addr `json:"newEmail"`
Password string `json:"password"`
}
type ChangeEmailPayload struct {
Success bool `json:"success"`
}
type ChangePasswordInput struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
type ChangePasswordPayload struct {
Success bool `json:"success"`
}
type CreateOrganizationInput struct {
Name string `json:"name"`
LogoFile *graphql.Upload `json:"logoFile,omitempty"`
HorizontalLogoFile *graphql.Upload `json:"horizontalLogoFile,omitempty"`
}
type CreateOrganizationPayload struct {
Organization *Organization `json:"organization,omitempty"`
MembershipEdge *MembershipEdge `json:"membershipEdge"`
}
type CreatePersonalAPIKeyInput struct {
Name string `json:"name"`
ExpiresAt time.Time `json:"expiresAt"`
OrganizationIds []gid.GID `json:"organizationIds"`
}
type CreatePersonalAPIKeyPayload struct {
PersonalAPIKeyEdge *PersonalAPIKeyEdge `json:"personalAPIKeyEdge"`
Token string `json:"token"`
}
type CreateSAMLConfigurationInput struct {
OrganizationID gid.GID `json:"organizationId"`
EmailDomain string `json:"emailDomain"`
IdpEntityID string `json:"idpEntityId"`
IdpSsoURL string `json:"idpSsoUrl"`
IdpCertificate string `json:"idpCertificate"`
AutoSignupEnabled bool `json:"autoSignupEnabled"`
AttributeMappings *SAMLAttributeMappingsInput `json:"attributeMappings,omitempty"`
}
type CreateSAMLConfigurationPayload struct {
SamlConfigurationEdge *SAMLConfigurationEdge `json:"samlConfigurationEdge"`
}
type CustomAttribute struct {
Key string `json:"key"`
Value string `json:"value"`
}
type DeactivateAccountInput struct {
Password string `json:"password"`
}
type DeactivateAccountPayload struct {
Success bool `json:"success"`
}
type DeleteAccountInput struct {
Password string `json:"password"`
Confirmation string `json:"confirmation"`
}
type DeleteAccountPayload struct {
Success bool `json:"success"`
}
type DeleteInvitationInput struct {
OrganizationID gid.GID `json:"organizationId"`
InvitationID gid.GID `json:"invitationId"`
}
type DeleteInvitationPayload struct {
DeletedInvitationID gid.GID `json:"deletedInvitationId"`
}
type DeleteOrganizationInput struct {
OrganizationID gid.GID `json:"organizationId"`
}
type DeleteOrganizationPayload struct {
DeletedOrganizationID gid.GID `json:"deletedOrganizationId"`
}
type DeleteSAMLConfigurationInput struct {
OrganizationID gid.GID `json:"organizationId"`
SamlConfigurationID gid.GID `json:"samlConfigurationId"`
}
type DeleteSAMLConfigurationPayload struct {
DeletedSamlConfigurationID gid.GID `json:"deletedSamlConfigurationId"`
}
type ForgotPasswordInput struct {
Email mail.Addr `json:"email"`
}
type ForgotPasswordPayload struct {
Success bool `json:"success"`
}
type Identity struct {
ID gid.GID `json:"id"`
Email mail.Addr `json:"email"`
EmailVerified bool `json:"emailVerified"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Memberships *MembershipConnection `json:"memberships"`
PendingInvitations *InvitationConnection `json:"pendingInvitations"`
Sessions *SessionConnection `json:"sessions"`
PersonalAPIKeys *PersonalAPIKeyConnection `json:"personalAPIKeys"`
ProfileFor *IdentityProfile `json:"profileFor,omitempty"`
}
func (Identity) IsNode() {}
func (this Identity) GetID() gid.GID { return this.ID }
type IdentityProfile struct {
ID gid.GID `json:"id"`
DisplayName string `json:"displayName"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
JobTitle *string `json:"jobTitle,omitempty"`
Department *string `json:"department,omitempty"`
PhoneNumber *string `json:"phoneNumber,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
Manager *IdentityProfile `json:"manager,omitempty"`
Timezone *string `json:"timezone,omitempty"`
Locale *string `json:"locale,omitempty"`
CustomAttributes []*CustomAttribute `json:"customAttributes"`
ProvisionedBy ProvisioningSource `json:"provisionedBy"`
ExternalID *string `json:"externalId,omitempty"`
Identity *Identity `json:"identity"`
Organization *Organization `json:"organization"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (IdentityProfile) IsNode() {}
func (this IdentityProfile) GetID() gid.GID { return this.ID }
type Invitation struct {
ID gid.GID `json:"id"`
Email mail.Addr `json:"email"`
ExpiresAt time.Time `json:"expiresAt"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Status coredata.InvitationStatus `json:"status"`
}
func (Invitation) IsNode() {}
func (this Invitation) GetID() gid.GID { return this.ID }
type InvitationEdge struct {
Node *Invitation `json:"node"`
Cursor page.CursorKey `json:"cursor"`
}
type InvitationProfile struct {
DisplayName string `json:"displayName"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
JobTitle *string `json:"jobTitle,omitempty"`
Department *string `json:"department,omitempty"`
}
type InvitationProfileInput struct {
DisplayName string `json:"displayName"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
JobTitle *string `json:"jobTitle,omitempty"`
Department *string `json:"department,omitempty"`
}
type InviteMemberInput struct {
OrganizationID gid.GID `json:"organizationId"`
Email mail.Addr `json:"email"`
FullName string `json:"fullName"`
}
type InviteMemberPayload struct {
InvitationEdge *InvitationEdge `json:"invitationEdge"`
}
type Membership struct {
ID gid.GID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Profile *IdentityProfile `json:"profile"`
Identity *Identity `json:"identity"`
Organization *Organization `json:"organization"`
Permissions []*Permission `json:"permissions"`
ProvisionedBy ProvisioningSource `json:"provisionedBy"`
Active bool `json:"active"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
}
func (Membership) IsNode() {}
func (this Membership) GetID() gid.GID { return this.ID }
type MembershipEdge struct {
Node *Membership `json:"node"`
Cursor page.CursorKey `json:"cursor"`
}
type Mutation struct {
}
type Organization struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
LogoURL *string `json:"logoUrl,omitempty"`
HorizontalLogoURL *string `json:"horizontalLogoUrl,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Members *MembershipConnection `json:"members"`
Invitations *InvitationConnection `json:"invitations"`
SamlConfigurations *SAMLConfigurationConnection `json:"samlConfigurations"`
AvailableApplications []*Application `json:"availableApplications"`
}
func (Organization) IsNode() {}
func (this Organization) GetID() gid.GID { return this.ID }
type PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"`
StartCursor *page.CursorKey `json:"startCursor,omitempty"`
EndCursor *page.CursorKey `json:"endCursor,omitempty"`
}
type Permission struct {
ID gid.GID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Application *Application `json:"application"`
AccessLevel AccessLevel `json:"accessLevel"`
Organization *Organization `json:"organization"`
PrincipalType PrincipalType `json:"principalType"`
PrincipalID gid.GID `json:"principalId"`
}
func (Permission) IsNode() {}
func (this Permission) GetID() gid.GID { return this.ID }
type PermissionGrant struct {
Application *Application `json:"application"`
AccessLevel AccessLevel `json:"accessLevel"`
}
type PersonalAPIKey struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
Scopes []TokenScope `json:"scopes"`
Organizations []*Organization `json:"organizations"`
}
func (PersonalAPIKey) IsNode() {}
func (this PersonalAPIKey) GetID() gid.GID { return this.ID }
type PersonalAPIKeyEdge struct {
Node *PersonalAPIKey `json:"node"`
Cursor page.CursorKey `json:"cursor"`
}
type Query struct {
}
type RemoveIPAllowlistEntryInput struct {
EntryID gid.GID `json:"entryId"`
}
type RemoveMemberInput struct {
OrganizationID gid.GID `json:"organizationId"`
MembershipID gid.GID `json:"membershipId"`
}
type RemoveMemberPayload struct {
DeletedMembershipID gid.GID `json:"deletedMembershipId"`
}
type ResetPasswordInput struct {
Token string `json:"token"`
Password string `json:"password"`
}
type ResetPasswordPayload struct {
Success bool `json:"success"`
}
type RevokeAllSessionsPayload struct {
RevokedCount int `json:"revokedCount"`
}
type RevokePersonalAPIKeyInput struct {
TokenID gid.GID `json:"tokenId"`
}
type RevokePersonalAPIKeyPayload struct {
Success bool `json:"success"`
}
type RevokeSessionInput struct {
SessionID gid.GID `json:"sessionId"`
}
type RevokeSessionPayload struct {
Success bool `json:"success"`
}
type SAMLAttributeMappings struct {
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Role string `json:"role"`
}
type SAMLAttributeMappingsInput struct {
Email *string `json:"email,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Role *string `json:"role,omitempty"`
}
type SAMLConfiguration struct {
ID gid.GID `json:"id"`
EmailDomain string `json:"emailDomain"`
Enabled bool `json:"enabled"`
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
DomainVerified bool `json:"domainVerified"`
DomainVerifiedAt *time.Time `json:"domainVerifiedAt,omitempty"`
DomainVerificationToken *string `json:"domainVerificationToken,omitempty"`
IdpEntityID string `json:"idpEntityId"`
IdpSsoURL string `json:"idpSsoUrl"`
IdpCertificate string `json:"idpCertificate"`
AutoSignupEnabled bool `json:"autoSignupEnabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
SpMetadataURL string `json:"spMetadataUrl"`
TestLoginURL string `json:"testLoginUrl"`
AttributeMappings *SAMLAttributeMappings `json:"attributeMappings"`
DefaultPermissions []*PermissionGrant `json:"defaultPermissions"`
}
func (SAMLConfiguration) IsNode() {}
func (this SAMLConfiguration) GetID() gid.GID { return this.ID }
type SAMLConfigurationEdge struct {
Node *SAMLConfiguration `json:"node"`
Cursor page.CursorKey `json:"cursor"`
}
type SSOAvailability struct {
Available bool `json:"available"`
SamlConfigID *gid.GID `json:"samlConfigId,omitempty"`
OrganizationID *gid.GID `json:"organizationId,omitempty"`
}
type Session struct {
ID gid.GID `json:"id"`
IPAddress string `json:"ipAddress"`
UserAgent string `json:"userAgent"`
UpdatedAt time.Time `json:"updatedAt"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
func (Session) IsNode() {}
func (this Session) GetID() gid.GID { return this.ID }
type SessionEdge struct {
Node *Session `json:"node"`
Cursor page.CursorKey `json:"cursor"`
}
type SessionOrder struct {
Direction page.OrderDirection `json:"direction"`
Field coredata.SessionOrderField `json:"field"`
}
type SessionPolicy struct {
MaxSessionDurationHours int `json:"maxSessionDurationHours"`
IdleTimeoutMinutes int `json:"idleTimeoutMinutes"`
MaxConcurrentSessions *int `json:"maxConcurrentSessions,omitempty"`
RequireReauthForSensitiveActions bool `json:"requireReauthForSensitiveActions"`
}
type SessionPolicyInput struct {
MaxSessionDurationHours *int `json:"maxSessionDurationHours,omitempty"`
IdleTimeoutMinutes *int `json:"idleTimeoutMinutes,omitempty"`
MaxConcurrentSessions *int `json:"maxConcurrentSessions,omitempty"`
RequireReauthForSensitiveActions *bool `json:"requireReauthForSensitiveActions,omitempty"`
}
type SignInInput struct {
Email mail.Addr `json:"email"`
Password string `json:"password"`
}
type SignInPayload struct {
Identity *Identity `json:"identity,omitempty"`
}
type SignOutPayload struct {
Success bool `json:"success"`
}
type SignUpFromInvitationInput struct {
Token string `json:"token"`
Password string `json:"password"`
}
type SignUpFromInvitationPayload struct {
Identity *Identity `json:"identity,omitempty"`
}
type SignUpInput struct {
Email mail.Addr `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
}
type SignUpPayload struct {
Identity *Identity `json:"identity,omitempty"`
}
type UpdateIdentityProfileInput struct {
MembershipID gid.GID `json:"membershipId"`
DisplayName *string `json:"displayName,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
JobTitle *string `json:"jobTitle,omitempty"`
Department *string `json:"department,omitempty"`
PhoneNumber *string `json:"phoneNumber,omitempty"`
Timezone *string `json:"timezone,omitempty"`
Locale *string `json:"locale,omitempty"`
}
type UpdateIdentityProfilePayload struct {
Profile *IdentityProfile `json:"profile,omitempty"`
}
type UpdateOrganizationInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name *string `json:"name,omitempty"`
LogoFile graphql.Omittable[*graphql.Upload] `json:"logoFile,omitempty"`
HorizontalLogoFile graphql.Omittable[*graphql.Upload] `json:"horizontalLogoFile,omitempty"`
}
type UpdateOrganizationPayload struct {
Organization *Organization `json:"organization,omitempty"`
}
type UpdatePersonalAPIKeyInput struct {
TokenID gid.GID `json:"tokenId"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
type UpdatePersonalAPIKeyPayload struct {
PersonalAPIKey *PersonalAPIKey `json:"personalAPIKey,omitempty"`
}
type UpdateSAMLConfigurationInput struct {
OrganizationID gid.GID `json:"organizationId"`
SamlConfigurationID gid.GID `json:"samlConfigurationId"`
IdpEntityID *string `json:"idpEntityId,omitempty"`
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
IdpCertificate *string `json:"idpCertificate,omitempty"`
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
EnforcementPolicy *coredata.SAMLEnforcementPolicy `json:"enforcementPolicy,omitempty"`
AttributeMappings *SAMLAttributeMappingsInput `json:"attributeMappings,omitempty"`
}
type UpdateSAMLConfigurationPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration,omitempty"`
}
type VerifyEmailInput struct {
Token string `json:"token"`
}
type VerifyEmailPayload struct {
Success bool `json:"success"`
}
type AccessLevel string
const (
AccessLevelRead AccessLevel = "READ"
AccessLevelWrite AccessLevel = "WRITE"
AccessLevelAdmin AccessLevel = "ADMIN"
)
var AllAccessLevel = []AccessLevel{
AccessLevelRead,
AccessLevelWrite,
AccessLevelAdmin,
}
func (e AccessLevel) IsValid() bool {
switch e {
case AccessLevelRead, AccessLevelWrite, AccessLevelAdmin:
return true
}
return false
}
func (e AccessLevel) String() string {
return string(e)
}
func (e *AccessLevel) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = AccessLevel(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid AccessLevel", str)
}
return nil
}
func (e AccessLevel) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *AccessLevel) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e AccessLevel) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type ApplicationID string
const (
ApplicationIDConsole ApplicationID = "CONSOLE"
ApplicationIDCompliance ApplicationID = "COMPLIANCE"
ApplicationIDRisk ApplicationID = "RISK"
ApplicationIDVendor ApplicationID = "VENDOR"
ApplicationIDDocuments ApplicationID = "DOCUMENTS"
ApplicationIDTrustCenter ApplicationID = "TRUST_CENTER"
ApplicationIDSettings ApplicationID = "SETTINGS"
ApplicationIDAPI ApplicationID = "API"
)
var AllApplicationID = []ApplicationID{
ApplicationIDConsole,
ApplicationIDCompliance,
ApplicationIDRisk,
ApplicationIDVendor,
ApplicationIDDocuments,
ApplicationIDTrustCenter,
ApplicationIDSettings,
ApplicationIDAPI,
}
func (e ApplicationID) IsValid() bool {
switch e {
case ApplicationIDConsole, ApplicationIDCompliance, ApplicationIDRisk, ApplicationIDVendor, ApplicationIDDocuments, ApplicationIDTrustCenter, ApplicationIDSettings, ApplicationIDAPI:
return true
}
return false
}
func (e ApplicationID) String() string {
return string(e)
}
func (e *ApplicationID) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ApplicationID(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ApplicationId", str)
}
return nil
}
func (e ApplicationID) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *ApplicationID) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e ApplicationID) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type AuthMethod string
const (
AuthMethodPassword AuthMethod = "PASSWORD"
AuthMethodSaml AuthMethod = "SAML"
AuthMethodRecoveryCode AuthMethod = "RECOVERY_CODE"
)
var AllAuthMethod = []AuthMethod{
AuthMethodPassword,
AuthMethodSaml,
AuthMethodRecoveryCode,
}
func (e AuthMethod) IsValid() bool {
switch e {
case AuthMethodPassword, AuthMethodSaml, AuthMethodRecoveryCode:
return true
}
return false
}
func (e AuthMethod) String() string {
return string(e)
}
func (e *AuthMethod) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = AuthMethod(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid AuthMethod", str)
}
return nil
}
func (e AuthMethod) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *AuthMethod) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e AuthMethod) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type PrincipalType string
const (
PrincipalTypeIdentity PrincipalType = "IDENTITY"
PrincipalTypeServiceAccount PrincipalType = "SERVICE_ACCOUNT"
)
var AllPrincipalType = []PrincipalType{
PrincipalTypeIdentity,
PrincipalTypeServiceAccount,
}
func (e PrincipalType) IsValid() bool {
switch e {
case PrincipalTypeIdentity, PrincipalTypeServiceAccount:
return true
}
return false
}
func (e PrincipalType) String() string {
return string(e)
}
func (e *PrincipalType) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = PrincipalType(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid PrincipalType", str)
}
return nil
}
func (e PrincipalType) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *PrincipalType) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e PrincipalType) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type ProvisioningSource string
const (
ProvisioningSourceManual ProvisioningSource = "MANUAL"
ProvisioningSourceInvitation ProvisioningSource = "INVITATION"
ProvisioningSourceSaml ProvisioningSource = "SAML"
)
var AllProvisioningSource = []ProvisioningSource{
ProvisioningSourceManual,
ProvisioningSourceInvitation,
ProvisioningSourceSaml,
}
func (e ProvisioningSource) IsValid() bool {
switch e {
case ProvisioningSourceManual, ProvisioningSourceInvitation, ProvisioningSourceSaml:
return true
}
return false
}
func (e ProvisioningSource) String() string {
return string(e)
}
func (e *ProvisioningSource) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ProvisioningSource(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ProvisioningSource", str)
}
return nil
}
func (e ProvisioningSource) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *ProvisioningSource) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e ProvisioningSource) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type SessionRequirement string
const (
SessionRequirementPresent SessionRequirement = "PRESENT"
SessionRequirementNone SessionRequirement = "NONE"
SessionRequirementOptional SessionRequirement = "OPTIONAL"
)
var AllSessionRequirement = []SessionRequirement{
SessionRequirementPresent,
SessionRequirementNone,
SessionRequirementOptional,
}
func (e SessionRequirement) IsValid() bool {
switch e {
case SessionRequirementPresent, SessionRequirementNone, SessionRequirementOptional:
return true
}
return false
}
func (e SessionRequirement) String() string {
return string(e)
}
func (e *SessionRequirement) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = SessionRequirement(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid SessionRequirement", str)
}
return nil
}
func (e SessionRequirement) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *SessionRequirement) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e SessionRequirement) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type TokenScope string
const (
TokenScopeReadOrganization TokenScope = "READ_ORGANIZATION"
TokenScopeWriteOrganization TokenScope = "WRITE_ORGANIZATION"
TokenScopeReadCompliance TokenScope = "READ_COMPLIANCE"
TokenScopeWriteCompliance TokenScope = "WRITE_COMPLIANCE"
TokenScopeReadRisk TokenScope = "READ_RISK"
TokenScopeWriteRisk TokenScope = "WRITE_RISK"
TokenScopeReadVendor TokenScope = "READ_VENDOR"
TokenScopeWriteVendor TokenScope = "WRITE_VENDOR"
TokenScopeReadDocuments TokenScope = "READ_DOCUMENTS"
TokenScopeWriteDocuments TokenScope = "WRITE_DOCUMENTS"
TokenScopeReadTrustCenter TokenScope = "READ_TRUST_CENTER"
TokenScopeWriteTrustCenter TokenScope = "WRITE_TRUST_CENTER"
TokenScopeAdmin TokenScope = "ADMIN"
)
var AllTokenScope = []TokenScope{
TokenScopeReadOrganization,
TokenScopeWriteOrganization,
TokenScopeReadCompliance,
TokenScopeWriteCompliance,
TokenScopeReadRisk,
TokenScopeWriteRisk,
TokenScopeReadVendor,
TokenScopeWriteVendor,
TokenScopeReadDocuments,
TokenScopeWriteDocuments,
TokenScopeReadTrustCenter,
TokenScopeWriteTrustCenter,
TokenScopeAdmin,
}
func (e TokenScope) IsValid() bool {
switch e {
case TokenScopeReadOrganization, TokenScopeWriteOrganization, TokenScopeReadCompliance, TokenScopeWriteCompliance, TokenScopeReadRisk, TokenScopeWriteRisk, TokenScopeReadVendor, TokenScopeWriteVendor, TokenScopeReadDocuments, TokenScopeWriteDocuments, TokenScopeReadTrustCenter, TokenScopeWriteTrustCenter, TokenScopeAdmin:
return true
}
return false
}
func (e TokenScope) String() string {
return string(e)
}
func (e *TokenScope) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = TokenScope(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid TokenScope", str)
}
return nil
}
func (e TokenScope) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *TokenScope) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e TokenScope) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,930 @@
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.84
import (
"context"
"errors"
"fmt"
"time"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/securecookie"
"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"
)
// Memberships is the resolver for the memberships field.
func (r *identityResolver) Memberships(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.MembershipConnection, error) {
pageOrderBy := page.OrderBy[coredata.MembershipOrderField]{
Field: coredata.MembershipOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.AccountService.ListMemberships(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list memberships: %w", err))
}
return types.NewMembershipConnection(page, r, obj.ID), nil
}
// PendingInvitations is the resolver for the pendingInvitations field.
func (r *identityResolver) PendingInvitations(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.InvitationConnection, error) {
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.AccountService.ListPendingInvitations(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list pending invitations: %w", err))
}
return types.NewInvitationConnection(page, r, obj.ID, nil), 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) {
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 {
panic(fmt.Errorf("cannot list sessions: %w", err))
}
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) {
pageOrderBy := page.OrderBy[coredata.UserAPIKeyOrderField]{
Field: coredata.UserAPIKeyOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.AccountService.ListPersonalAPIKeys(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list personal api keys: %w", err))
}
return types.NewPersonalAPIKeyConnection(page, r, obj.ID), nil
}
// TotalCount is the resolver for the totalCount field.
func (r *invitationConnectionResolver) TotalCount(ctx context.Context, obj *types.InvitationConnection) (int, error) {
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := r.iam.OrganizationService.CountInvitations(ctx, obj.ParentID, obj.Filters)
if err != nil {
panic(fmt.Errorf("cannot count invitations: %w", err))
}
return count, nil
case *identityResolver:
count, err := r.iam.AccountService.CountPendingInvitations(ctx, obj.ParentID)
if err != nil {
panic(fmt.Errorf("cannot count invitations: %w", err))
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// Identity is the resolver for the identity field.
func (r *membershipResolver) Identity(ctx context.Context, obj *types.Membership) (*types.Identity, error) {
identity, err := r.iam.AccountService.GetIdentityForMembership(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get identity: %w", err))
}
return types.NewIdentity(identity), nil
}
// Organization is the resolver for the organization field.
func (r *membershipResolver) Organization(ctx context.Context, obj *types.Membership) (*types.Organization, error) {
organization, err := r.iam.OrganizationService.GetOrganizationForMembership(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get organization for membership: %w", err))
}
return types.NewOrganization(organization), nil
}
// TotalCount is the resolver for the totalCount field.
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
switch obj.Resolver.(type) {
case *identityResolver:
count, err := r.iam.AccountService.CountMemberships(ctx, obj.ParentID)
if err != nil {
panic(fmt.Errorf("cannot count memberships: %w", err))
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// SignIn is the resolver for the signIn field.
func (r *mutationResolver) SignIn(ctx context.Context, input types.SignInInput) (*types.SignInPayload, error) {
user, session, err := r.iam.AuthService.OpenSessionWithPassword(ctx, input.Email, input.Password)
if err != nil {
var ErrInvalidCredentials *iam.ErrInvalidCredentials
if errors.As(err, &ErrInvalidCredentials) {
return nil, &gqlerror.Error{
Message: err.Error(),
Extensions: map[string]any{
"code": "INVALID_CREDENTIALS",
},
}
}
// TODO handle error properly here
panic(fmt.Errorf("cannot sign in: %w", err))
}
w := HTTPResponseWriterFromContext(ctx)
securecookie.Set(
w,
r.sessionCookieConfig(time.Until(session.ExpiredAt)),
session.ID.String(),
)
return &types.SignInPayload{
Identity: &types.Identity{
ID: user.ID,
Email: user.EmailAddress,
EmailVerified: user.EmailAddressVerified,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
}, nil
}
// SignUp is the resolver for the signUp field.
func (r *mutationResolver) SignUp(ctx context.Context, input types.SignUpInput) (*types.SignUpPayload, error) {
identity, session, err := r.iam.AuthService.CreateIdentityWithPassword(
ctx,
&iam.CreateIdentityWithPasswordRequest{
Email: input.Email,
Password: input.Password,
FullName: input.FullName,
},
)
if err != nil {
// TODO handle error properly here
panic(fmt.Errorf("cannot create identity with password: %w", err))
}
w := HTTPResponseWriterFromContext(ctx)
securecookie.Set(
w,
r.sessionCookieConfig(time.Until(session.ExpiredAt)),
session.ID.String(),
)
return &types.SignUpPayload{
Identity: types.NewIdentity(identity),
}, nil
}
// SignOut is the resolver for the signOut field.
func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload, error) {
session := SessionFromContext(ctx)
err := r.iam.SessionService.CloseSession(ctx, session.ID)
if err != nil {
var ErrSessionNotFound *iam.ErrSessionNotFound
if errors.As(err, &ErrSessionNotFound) {
return &types.SignOutPayload{}, nil
}
panic(fmt.Errorf("cannot close session: %w", err))
}
return &types.SignOutPayload{Success: true}, nil
}
// SignUpFromInvitation is the resolver for the signUpFromInvitation field.
func (r *mutationResolver) SignUpFromInvitation(ctx context.Context, input types.SignUpFromInvitationInput) (*types.SignUpFromInvitationPayload, error) {
identity, session, err := r.iam.AuthService.CreateIdentityFromInvitation(
ctx,
&iam.CreateIdentityFromInvitationRequest{
InvitationToken: input.Token,
Password: input.Password,
},
)
if err != nil {
// TODO handle error properly here
panic(fmt.Errorf("cannot create identity from invitation: %w", err))
}
w := HTTPResponseWriterFromContext(ctx)
securecookie.Set(
w,
r.sessionCookieConfig(time.Until(session.ExpiredAt)),
session.ID.String(),
)
return &types.SignUpFromInvitationPayload{
Identity: &types.Identity{
ID: identity.ID,
Email: identity.EmailAddress,
EmailVerified: identity.EmailAddressVerified,
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
},
}, nil
}
// ForgotPassword is the resolver for the forgotPassword field.
func (r *mutationResolver) ForgotPassword(ctx context.Context, input types.ForgotPasswordInput) (*types.ForgotPasswordPayload, error) {
err := r.iam.AuthService.SendPasswordResetInstructionByEmail(
ctx,
input.Email,
)
if err != nil {
// TODO handle error properly here
panic(fmt.Errorf("cannot send password reset instruction by email: %w", err))
}
return &types.ForgotPasswordPayload{
Success: true,
}, nil
}
// ResetPassword is the resolver for the resetPassword field.
func (r *mutationResolver) ResetPassword(ctx context.Context, input types.ResetPasswordInput) (*types.ResetPasswordPayload, error) {
err := r.iam.AuthService.ResetPassword(
ctx,
&iam.ResetPasswordRequest{
Token: input.Token,
Password: input.Password,
},
)
if err != nil {
var errInvalidToken *iam.ErrInvalidToken
if errors.As(err, &errInvalidToken) {
return nil, gqlutils.Invalid(err, nil)
}
panic(fmt.Errorf("cannot reset password: %w", err))
}
return &types.ResetPasswordPayload{
Success: true,
}, nil
}
// VerifyEmail is the resolver for the verifyEmail field.
func (r *mutationResolver) VerifyEmail(ctx context.Context, input types.VerifyEmailInput) (*types.VerifyEmailPayload, error) {
err := r.iam.AccountService.VerifyEmail(ctx, input.Token)
if err != nil {
// TODO handle error properly here
panic(fmt.Errorf("cannot verify email: %w", err))
}
return &types.VerifyEmailPayload{
Success: true,
}, nil
}
// ChangePassword is the resolver for the changePassword field.
func (r *mutationResolver) ChangePassword(ctx context.Context, input types.ChangePasswordInput) (*types.ChangePasswordPayload, error) {
identity := UserFromContext(ctx)
err := r.iam.AccountService.ChangePassword(
ctx,
identity.ID,
&iam.ChangePasswordRequest{
CurrentPassword: input.CurrentPassword,
NewPassword: input.NewPassword,
},
)
if err != nil {
// TODO handle error properly here
panic(fmt.Errorf("cannot change password: %w", err))
}
return &types.ChangePasswordPayload{
Success: true,
}, nil
}
// ChangeEmail is the resolver for the changeEmail field.
func (r *mutationResolver) ChangeEmail(ctx context.Context, input types.ChangeEmailInput) (*types.ChangeEmailPayload, error) {
identity := UserFromContext(ctx)
err := r.iam.AccountService.ChangeEmail(
ctx,
identity.ID,
&iam.ChangeEmailRequest{
NewEmail: input.NewEmail,
Password: input.Password,
},
)
if err != nil {
// TODO handle error properly here
panic(fmt.Errorf("cannot change email: %w", err))
}
return &types.ChangeEmailPayload{
Success: true,
}, nil
}
// UpdateIdentityProfile is the resolver for the updateIdentityProfile field.
func (r *mutationResolver) UpdateIdentityProfile(ctx context.Context, input types.UpdateIdentityProfileInput) (*types.UpdateIdentityProfilePayload, error) {
panic(fmt.Errorf("not implemented: UpdateIdentityProfile - updateIdentityProfile"))
}
// RevokeSession is the resolver for the revokeSession field.
func (r *mutationResolver) RevokeSession(ctx context.Context, input types.RevokeSessionInput) (*types.RevokeSessionPayload, error) {
identity := UserFromContext(ctx)
err := r.iam.SessionService.RevokeSession(ctx, identity.ID, input.SessionID)
if err != nil {
var ErrSessionExpired *iam.ErrSessionExpired
if errors.As(err, &ErrSessionExpired) {
return &types.RevokeSessionPayload{Success: true}, nil
}
panic(fmt.Errorf("cannot revoke session: %w", err))
}
return &types.RevokeSessionPayload{Success: true}, nil
}
// RevokeAllSessions is the resolver for the revokeAllSessions field.
func (r *mutationResolver) RevokeAllSessions(ctx context.Context) (*types.RevokeAllSessionsPayload, error) {
session := SessionFromContext(ctx)
revokedCount, err := r.iam.SessionService.RevokeAllSessions(ctx, session.ID)
if err != nil {
panic(fmt.Errorf("cannot revoke all sessions: %w", err))
}
return &types.RevokeAllSessionsPayload{RevokedCount: int(revokedCount)}, nil
}
// CreatePersonalAPIKey is the resolver for the createPersonalAPIKey field.
func (r *mutationResolver) CreatePersonalAPIKey(ctx context.Context, input types.CreatePersonalAPIKeyInput) (*types.CreatePersonalAPIKeyPayload, error) {
identity := UserFromContext(ctx)
userAPIKey, token, err := r.iam.AccountService.CreatePersonalAPIKey(
ctx,
identity.ID,
input.Name,
input.ExpiresAt,
)
if err != nil {
panic(fmt.Errorf("cannot create personal api key: %w", err))
}
return &types.CreatePersonalAPIKeyPayload{
PersonalAPIKeyEdge: types.NewPersonalAPIKeyEdge(userAPIKey, coredata.UserAPIKeyOrderFieldCreatedAt),
Token: token,
}, nil
}
// UpdatePersonalAPIKey is the resolver for the updatePersonalAPIKey field.
func (r *mutationResolver) UpdatePersonalAPIKey(ctx context.Context, input types.UpdatePersonalAPIKeyInput) (*types.UpdatePersonalAPIKeyPayload, error) {
panic(fmt.Errorf("not implemented: UpdatePersonalAPIKey - updatePersonalAPIKey"))
}
// RevokePersonalAPIKey is the resolver for the revokePersonalAPIKey field.
func (r *mutationResolver) RevokePersonalAPIKey(ctx context.Context, input types.RevokePersonalAPIKeyInput) (*types.RevokePersonalAPIKeyPayload, error) {
identity := UserFromContext(ctx)
err := r.iam.AccountService.DeletePersonalAPIKey(ctx, identity.ID, input.TokenID)
if err != nil {
panic(fmt.Errorf("cannot delete personal api key: %w", err))
}
return &types.RevokePersonalAPIKeyPayload{Success: true}, nil
}
// CreateOrganization is the resolver for the createOrganization field.
func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error) {
identity := UserFromContext(ctx)
var (
logoFile *iam.UploadedFile
horizontalLogoFile *iam.UploadedFile
)
if input.LogoFile != nil {
logoFile = &iam.UploadedFile{
Content: input.LogoFile.File,
Filename: input.LogoFile.Filename,
ContentType: input.LogoFile.ContentType,
Size: input.LogoFile.Size,
}
}
if input.HorizontalLogoFile != nil {
horizontalLogoFile = &iam.UploadedFile{
Content: input.HorizontalLogoFile.File,
Filename: input.HorizontalLogoFile.Filename,
ContentType: input.HorizontalLogoFile.ContentType,
Size: input.HorizontalLogoFile.Size,
}
}
organization, err := r.iam.OrganizationService.CreateOrganization(
ctx,
identity.ID,
&iam.CreateOrganizationRequest{
Name: input.Name,
LogoFile: logoFile,
HorizontalLogoFile: horizontalLogoFile,
},
)
if err != nil {
panic(fmt.Errorf("cannot create organization: %w", err))
}
return &types.CreateOrganizationPayload{
Organization: types.NewOrganization(organization),
}, nil
}
// UpdateOrganization is the resolver for the updateOrganization field.
func (r *mutationResolver) UpdateOrganization(ctx context.Context, input types.UpdateOrganizationInput) (*types.UpdateOrganizationPayload, error) {
organization, err := r.iam.OrganizationService.UpdateOrganization(
ctx,
input.OrganizationID,
&iam.UpdateOrganizationRequest{
Name: input.Name,
},
)
if err != nil {
panic(fmt.Errorf("cannot update organization: %w", err))
}
return &types.UpdateOrganizationPayload{
Organization: &types.Organization{
ID: organization.ID,
Name: organization.Name,
CreatedAt: organization.CreatedAt,
UpdatedAt: organization.UpdatedAt,
},
}, nil
}
// DeleteOrganization is the resolver for the deleteOrganization field.
func (r *mutationResolver) DeleteOrganization(ctx context.Context, input types.DeleteOrganizationInput) (*types.DeleteOrganizationPayload, error) {
err := r.iam.OrganizationService.DeleteOrganization(ctx, input.OrganizationID)
if err != nil {
panic(fmt.Errorf("cannot delete organization: %w", err))
}
return &types.DeleteOrganizationPayload{DeletedOrganizationID: input.OrganizationID}, nil
}
// InviteMember is the resolver for the inviteMember field.
func (r *mutationResolver) InviteMember(ctx context.Context, input types.InviteMemberInput) (*types.InviteMemberPayload, error) {
invitation, err := r.iam.OrganizationService.InviteMember(
ctx,
input.OrganizationID,
input.Email,
input.FullName,
coredata.MembershipRoleViewer,
)
if err != nil {
var errOrganizationNotFound *iam.ErrOrganizationNotFound
var errMembershipAlreadyExists *iam.ErrMembershipAlreadyExists
if errors.As(err, &errOrganizationNotFound) {
return nil, gqlutils.NotFound(err)
}
if errors.As(err, &errMembershipAlreadyExists) {
return nil, gqlutils.Conflict(err)
}
panic(fmt.Errorf("cannot add member to organization: %w", err))
}
return &types.InviteMemberPayload{
InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt),
}, nil
}
// DeleteInvitation is the resolver for the deleteInvitation field.
func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.DeleteInvitationInput) (*types.DeleteInvitationPayload, error) {
err := r.iam.OrganizationService.DeleteInvitation(ctx, input.OrganizationID, input.InvitationID)
if err != nil {
var errInvitationNotFound *iam.ErrInvitationNotFound
var errInvitationNotPending *iam.ErrInvitationNotPending
if errors.As(err, &errInvitationNotFound) {
return nil, gqlutils.NotFound(err)
}
if errors.As(err, &errInvitationNotPending) {
return nil, gqlutils.Invalid(err, nil)
}
panic(fmt.Errorf("cannot delete invitation: %w", err))
}
return &types.DeleteInvitationPayload{DeletedInvitationID: input.InvitationID}, nil
}
// RemoveMember is the resolver for the removeMember field.
func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) {
err := r.iam.OrganizationService.RemoveMember(ctx, input.OrganizationID, input.MembershipID)
if err != nil {
panic(fmt.Errorf("cannot remove member from organization: %w", err))
}
return &types.RemoveMemberPayload{DeletedMembershipID: input.MembershipID}, nil
}
// AcceptInvitation is the resolver for the acceptInvitation field.
func (r *mutationResolver) AcceptInvitation(ctx context.Context, input types.AcceptInvitationInput) (*types.AcceptInvitationPayload, error) {
identity := UserFromContext(ctx)
membership, err := r.iam.AccountService.AcceptInvitation(ctx, identity.ID, input.InvitationID)
if err != nil {
panic(fmt.Errorf("cannot accept invitation: %w", err))
}
return &types.AcceptInvitationPayload{
MembershipEdge: types.NewMembershipEdge(membership, coredata.MembershipOrderFieldCreatedAt),
}, nil
}
// CreateSAMLConfiguration is the resolver for the createSAMLConfiguration field.
func (r *mutationResolver) CreateSAMLConfiguration(ctx context.Context, input types.CreateSAMLConfigurationInput) (*types.CreateSAMLConfigurationPayload, error) {
req := &iam.CreateSAMLConfigurationRequest{
EmailDomain: input.EmailDomain,
IdPEntityID: input.IdpEntityID,
IdPSsoURL: input.IdpSsoURL,
IdPCertificate: input.IdpCertificate,
AutoSignupEnabled: input.AutoSignupEnabled,
}
if input.AttributeMappings != nil {
req.AttributeEmail = input.AttributeMappings.Email
req.AttributeFirstname = input.AttributeMappings.FirstName
req.AttributeLastname = input.AttributeMappings.LastName
req.AttributeRole = input.AttributeMappings.Role
}
samlConfiguration, err := r.iam.OrganizationService.CreateSAMLConfiguration(
ctx,
input.OrganizationID,
req,
)
if err != nil {
panic(fmt.Errorf("cannot create saml configuration: %w", err))
}
return &types.CreateSAMLConfigurationPayload{
SamlConfigurationEdge: types.NewSAMLConfigurationEdge(
samlConfiguration,
coredata.SAMLConfigurationOrderFieldCreatedAt,
),
}, nil
}
// UpdateSAMLConfiguration is the resolver for the updateSAMLConfiguration field.
func (r *mutationResolver) UpdateSAMLConfiguration(ctx context.Context, input types.UpdateSAMLConfigurationInput) (*types.UpdateSAMLConfigurationPayload, error) {
req := &iam.UpdateSAMLConfigurationRequest{
IdPEntityID: input.IdpEntityID,
IdPSsoURL: input.IdpSsoURL,
IdPCertificate: input.IdpCertificate,
AutoSignupEnabled: input.AutoSignupEnabled,
}
if input.AttributeMappings != nil {
req.AttributeEmail = input.AttributeMappings.Email
req.AttributeFirstname = input.AttributeMappings.FirstName
req.AttributeLastname = input.AttributeMappings.LastName
req.AttributeRole = input.AttributeMappings.Role
}
samlConfiguration, err := r.iam.OrganizationService.UpdateSAMLConfiguration(
ctx,
input.OrganizationID,
input.SamlConfigurationID,
req,
)
if err != nil {
panic(fmt.Errorf("cannot update saml configuration: %w", err))
}
return &types.UpdateSAMLConfigurationPayload{
SamlConfiguration: types.NewSAMLConfiguration(samlConfiguration),
}, nil
}
// DeleteSAMLConfiguration is the resolver for the deleteSAMLConfiguration field.
func (r *mutationResolver) DeleteSAMLConfiguration(ctx context.Context, input types.DeleteSAMLConfigurationInput) (*types.DeleteSAMLConfigurationPayload, error) {
err := r.iam.OrganizationService.DeleteSAMLConfiguration(ctx, input.OrganizationID, input.SamlConfigurationID)
if err != nil {
panic(fmt.Errorf("cannot delete saml configuration: %w", err))
}
return &types.DeleteSAMLConfigurationPayload{DeletedSamlConfigurationID: input.SamlConfigurationID}, nil
}
// LogoURL is the resolver for the logoUrl field.
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
presignedURL, err := r.iam.OrganizationService.GenerateLogoURL(ctx, obj.ID, 1*time.Hour)
if err != nil {
panic(fmt.Errorf("cannot generate logo URL: %w", err))
}
return presignedURL, nil
}
// HorizontalLogoURL is the resolver for the horizontalLogoUrl field.
func (r *organizationResolver) HorizontalLogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
presignedURL, err := r.iam.OrganizationService.GenerateHorizontalLogoURL(ctx, obj.ID, 1*time.Hour)
if err != nil {
panic(fmt.Errorf("cannot generate horizontal logo URL: %w", err))
}
return presignedURL, nil
}
// Members is the resolver for the members field.
func (r *organizationResolver) Members(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.MembershipConnection, error) {
pageOrderBy := page.OrderBy[coredata.MembershipOrderField]{
Field: coredata.MembershipOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.OrganizationService.ListMembers(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list memberships: %w", err))
}
return types.NewMembershipConnection(page, r, obj.ID), nil
}
// Invitations is the resolver for the invitations field.
func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, status *coredata.InvitationStatus) (*types.InvitationConnection, error) {
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
filters := coredata.NewInvitationFilter(nil)
if status != nil {
filters = coredata.NewInvitationFilter([]coredata.InvitationStatus{*status})
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.OrganizationService.ListInvitations(ctx, obj.ID, cursor, filters)
if err != nil {
panic(fmt.Errorf("cannot list invitations: %w", err))
}
return types.NewInvitationConnection(page, r, obj.ID, filters), nil
}
// SamlConfigurations is the resolver for the samlConfigurations field.
func (r *organizationResolver) SamlConfigurations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.SAMLConfigurationConnection, error) {
pageOrderBy := page.OrderBy[coredata.SAMLConfigurationOrderField]{
Field: coredata.SAMLConfigurationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.OrganizationService.ListSAMLConfigurations(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list saml configurations: %w", err))
}
return types.NewSAMLConfigurationConnection(page, r, obj.ID), nil
}
// TotalCount is the resolver for the totalCount field.
func (r *personalAPIKeyConnectionResolver) TotalCount(ctx context.Context, obj *types.PersonalAPIKeyConnection) (int, error) {
switch obj.Resolver.(type) {
case *identityResolver:
count, err := r.iam.AccountService.CountPersonalAPIKeys(ctx, obj.ParentID)
if err != nil {
panic(fmt.Errorf("cannot count personal api keys: %w", err))
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// Node is the resolver for the node field.
func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error) {
var loadNode func(ctx context.Context, id gid.GID) (types.Node, error)
user := UserFromContext(ctx)
r.iam.AccessManagementService.Authorize(ctx, user.ID, nil, id, iam.ActionGet)
switch id.EntityType() {
case coredata.OrganizationEntityType:
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
organization, err := r.iam.OrganizationService.GetOrganization(ctx, id)
if err != nil {
return nil, err
}
return types.NewOrganization(organization), nil
}
case coredata.UserEntityType:
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
identity, err := r.iam.AccountService.GetIdentity(ctx, id)
if err != nil {
return nil, err
}
return types.NewIdentity(identity), nil
}
case coredata.SessionEntityType:
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
session, err := r.iam.GetSession(ctx, id)
if err != nil {
return nil, err
}
return types.NewSession(session), nil
}
case coredata.MembershipEntityType:
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
membership, err := r.iam.GetMembership(ctx, id)
if err != nil {
return nil, err
}
return types.NewMembership(membership), nil
}
case coredata.InvitationEntityType:
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
invitation, err := r.iam.GetInvitation(ctx, id)
if err != nil {
return nil, err
}
return types.NewInvitation(invitation), nil
}
default:
return nil, fmt.Errorf("unsupported entity type: %d", id.EntityType())
}
node, err := loadNode(ctx, id)
if err != nil {
var (
errOrganizationNotFound *iam.ErrOrganizationNotFound
errIdentityNotFound *iam.ErrUserNotFound
errSessionNotFound *iam.ErrSessionNotFound
errMembershipNotFound *iam.ErrMembershipNotFound
errInvitationNotFound *iam.ErrInvitationNotFound
)
if errors.As(err, &errOrganizationNotFound) ||
errors.As(err, &errIdentityNotFound) ||
errors.As(err, &errSessionNotFound) ||
errors.As(err, &errMembershipNotFound) ||
errors.As(err, &errInvitationNotFound) {
return nil, gqlutils.NotFound(err)
}
return nil, err
}
return node, nil
}
// Viewer is the resolver for the viewer field.
func (r *queryResolver) Viewer(ctx context.Context) (*types.Identity, error) {
user := UserFromContext(ctx)
return &types.Identity{
ID: user.ID,
Email: user.EmailAddress,
EmailVerified: user.EmailAddressVerified,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
// CheckSSOAvailability is the resolver for the checkSSOAvailability field.
func (r *queryResolver) CheckSSOAvailability(ctx context.Context, email string) (*types.SSOAvailability, error) {
panic(fmt.Errorf("not implemented: CheckSSOAvailability - checkSSOAvailability"))
}
// TotalCount is the resolver for the totalCount field.
func (r *sAMLConfigurationConnectionResolver) TotalCount(ctx context.Context, obj *types.SAMLConfigurationConnection) (int, error) {
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := r.iam.OrganizationService.CountSAMLConfigurations(ctx, obj.ParentID)
if err != nil {
panic(fmt.Errorf("cannot count saml configurations: %w", err))
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// TotalCount is the resolver for the totalCount field.
func (r *sessionConnectionResolver) TotalCount(ctx context.Context, obj *types.SessionConnection) (int, error) {
switch obj.Resolver.(type) {
case *identityResolver:
count, err := r.iam.AccountService.CountSessions(ctx, obj.ParentID)
if err != nil {
panic(fmt.Errorf("cannot count sessions: %w", err))
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// Identity returns schema.IdentityResolver implementation.
func (r *Resolver) Identity() schema.IdentityResolver { return &identityResolver{r} }
// InvitationConnection returns schema.InvitationConnectionResolver implementation.
func (r *Resolver) InvitationConnection() schema.InvitationConnectionResolver {
return &invitationConnectionResolver{r}
}
// Membership returns schema.MembershipResolver implementation.
func (r *Resolver) Membership() schema.MembershipResolver { return &membershipResolver{r} }
// MembershipConnection returns schema.MembershipConnectionResolver implementation.
func (r *Resolver) MembershipConnection() schema.MembershipConnectionResolver {
return &membershipConnectionResolver{r}
}
// Mutation returns schema.MutationResolver implementation.
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }
// Organization returns schema.OrganizationResolver implementation.
func (r *Resolver) Organization() schema.OrganizationResolver { return &organizationResolver{r} }
// PersonalAPIKeyConnection returns schema.PersonalAPIKeyConnectionResolver implementation.
func (r *Resolver) PersonalAPIKeyConnection() schema.PersonalAPIKeyConnectionResolver {
return &personalAPIKeyConnectionResolver{r}
}
// Query returns schema.QueryResolver implementation.
func (r *Resolver) Query() schema.QueryResolver { return &queryResolver{r} }
// SAMLConfigurationConnection returns schema.SAMLConfigurationConnectionResolver implementation.
func (r *Resolver) SAMLConfigurationConnection() schema.SAMLConfigurationConnectionResolver {
return &sAMLConfigurationConnectionResolver{r}
}
// SessionConnection returns schema.SessionConnectionResolver implementation.
func (r *Resolver) SessionConnection() schema.SessionConnectionResolver {
return &sessionConnectionResolver{r}
}
type identityResolver struct{ *Resolver }
type invitationConnectionResolver struct{ *Resolver }
type membershipResolver struct{ *Resolver }
type membershipConnectionResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type organizationResolver struct{ *Resolver }
type personalAPIKeyConnectionResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type sAMLConfigurationConnectionResolver struct{ *Resolver }
type sessionConnectionResolver struct{ *Resolver }

View File

@@ -22,85 +22,84 @@ import (
"fmt"
"net/http"
"strings"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/go-chi/chi/v5"
"github.com/vektah/gqlparser/v2/ast"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/saferedirect"
"go.probo.inc/probo/pkg/securecookie"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
serverauth "go.probo.inc/probo/pkg/server/auth"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/server/session"
"go.probo.inc/probo/pkg/statelesstoken"
)
type (
AuthConfig struct {
CookieName string
CookieDomain string
SessionDuration time.Duration
CookieSecret string
CookieSecure bool
}
Resolver struct {
proboSvc *probo.Service
authSvc *auth.Service
authzSvc *authz.Service
samlSvc *auth.SAMLService
authCfg AuthConfig
probo *probo.Service
iam *iam.Service
customDomainCname string
schema *ast.Schema
}
)
func ensureAuthenticated(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
identity := connect_v1.UserFromContext(ctx)
if identity == nil {
return func(ctx context.Context) *graphql.Response {
return &graphql.Response{
Errors: gqlerror.List{
gqlutils.Unauthorized(),
},
}
}
}
ctxKey struct{ name string }
)
var (
sessionContextKey = &ctxKey{name: "session"}
)
func SessionFromContext(ctx context.Context) *coredata.Session {
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
return session
}
func UserFromContext(ctx context.Context) *coredata.User {
return serverauth.UserFromContext(ctx)
}
func UserAPIKeyFromContext(ctx context.Context) *coredata.UserAPIKey {
return serverauth.UserAPIKeyFromContext(ctx)
return next(ctx)
}
func NewMux(
logger *log.Logger,
proboSvc *probo.Service,
authSvc *auth.Service,
authzSvc *authz.Service,
authCfg AuthConfig,
iamSvc *iam.Service,
cookieConfig securecookie.Config,
tokenSecret string,
connectorRegistry *connector.ConnectorRegistry,
safeRedirect *saferedirect.SafeRedirect,
baseURL *baseurl.BaseURL,
customDomainCname string,
samlSvc *auth.SAMLService,
) *chi.Mux {
r := chi.NewMux()
safeRedirect := &saferedirect.SafeRedirect{AllowedHost: baseURL.Host()}
sessionMiddleware := connect_v1.NewSessionMiddleware(iamSvc, cookieConfig)
apiKeyMiddleware := connect_v1.NewAPIKeyMiddleware(iamSvc)
r.Use(sessionMiddleware)
r.Use(apiKeyMiddleware)
config := schema.Config{
Resolvers: &Resolver{
probo: proboSvc,
iam: iamSvc,
customDomainCname: customDomainCname,
},
}
es := schema.NewExecutableSchema(config)
h := gqlutils.NewHandler(es, logger)
h.AroundOperations(ensureAuthenticated)
r.Handle("/graphql", h)
r.Get(
"/documents/signing-requests",
func(w http.ResponseWriter, r *http.Request) {
@@ -111,7 +110,7 @@ func NewMux(
}
token = strings.TrimPrefix(token, "Bearer ")
data, err := statelesstoken.ValidateToken[probo.SigningRequestData](authCfg.CookieSecret, probo.TokenTypeSigningRequest, token)
data, err := statelesstoken.ValidateToken[probo.SigningRequestData](tokenSecret, probo.TokenTypeSigningRequest, token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
@@ -139,7 +138,7 @@ func NewMux(
return
}
data, err := statelesstoken.ValidateToken[probo.SigningRequestData](authCfg.CookieSecret, probo.TokenTypeSigningRequest, token)
data, err := statelesstoken.ValidateToken[probo.SigningRequestData](tokenSecret, probo.TokenTypeSigningRequest, token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
@@ -194,7 +193,7 @@ func NewMux(
}
token = strings.TrimPrefix(token, "Bearer ")
data, err := statelesstoken.ValidateToken[probo.SigningRequestData](authCfg.CookieSecret, probo.TokenTypeSigningRequest, token)
data, err := statelesstoken.ValidateToken[probo.SigningRequestData](tokenSecret, probo.TokenTypeSigningRequest, token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
@@ -217,7 +216,7 @@ func NewMux(
},
)
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
r.Get("/connectors/initiate", func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider != "SLACK" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
@@ -229,7 +228,23 @@ func NewMux(
panic(fmt.Errorf("cannot parse organization id: %w", err))
}
_ = GetTenantService(r.Context(), proboSvc, organizationID.TenantID())
identity := connect_v1.UserFromContext(r.Context())
apiKey := connect_v1.APIKeyFromContext(r.Context())
if identity == nil {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
return
}
var credentialID *gid.GID
if apiKey != nil {
credentialID = &apiKey.ID
}
// Ensure the actor (and optional API key) can access this organization.
if err := iamSvc.AccessManagementService.Authorize(r.Context(), identity.ID, credentialID, organizationID, iam.ActionGet); err != nil {
httpserver.RenderError(w, http.StatusForbidden, err)
return
}
redirectURL, err := connectorRegistry.Initiate(r.Context(), provider, organizationID, r)
if err != nil {
@@ -239,7 +254,7 @@ func NewMux(
// Allow external redirects for Slack OAuth only for now
slackSafeRedirect := &saferedirect.SafeRedirect{AllowedHost: "slack.com"}
slackSafeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
}))
})
r.Get("/connectors/complete", func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
@@ -288,146 +303,16 @@ func NewMux(
if continueURL != "" {
safeRedirect.Redirect(w, r, continueURL, "/", http.StatusSeeOther)
} else {
redirectURL := fmt.Sprintf("/organizations/%s", organizationID.String())
redirectURL := baseURL.WithPath("/organizations/" + organizationID.String()).MustString()
safeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
}
})
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, samlSvc, authCfg, customDomainCname))
return r
}
// GetSchema returns the parsed GraphQL schema for the console API
// This is used by other services like authz to extract permissions from @mustBeAuthorized directives
func GetSchema() *ast.Schema {
execSchema := schema.NewExecutableSchema(schema.Config{})
return execSchema.Schema()
}
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, samlSvc *auth.SAMLService, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
var mb int64 = 1 << 20
// Parse the schema first to make it available to resolvers
execSchema := schema.NewExecutableSchema(schema.Config{})
cfg := schema.Config{
Resolvers: &Resolver{
proboSvc: proboSvc,
authSvc: authSvc,
authzSvc: authzSvc,
samlSvc: samlSvc,
authCfg: authCfg,
customDomainCname: customDomainCname,
schema: execSchema.Schema(),
},
}
es := schema.NewExecutableSchema(cfg)
srv := handler.New(es)
srv.AddTransport(transport.POST{})
srv.AddTransport(
transport.MultipartForm{
MaxMemory: 32 * mb,
MaxUploadSize: 50 * mb,
},
)
srv.Use(extension.Introspection{})
srv.Use(gqlutils.NewTracingExtension(logger))
srv.SetRecoverFunc(gqlutils.RecoverFunc)
srv.AroundOperations(
func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
user := UserFromContext(ctx)
if user == nil {
return func(ctx context.Context) *graphql.Response {
return &graphql.Response{
Errors: gqlerror.List{
&gqlerror.Error{
Message: "authentication required",
Extensions: map[string]any{
"code": "UNAUTHENTICATED",
},
},
},
}
}
}
return next(ctx)
},
)
return WithSession(authSvc, authzSvc, authCfg, srv.ServeHTTP)
}
func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if authCtx := serverauth.AuthenticateWithAPIKey(ctx, r, authSvc, authzSvc); authCtx != nil {
next(w, r.WithContext(authCtx))
return
}
sessionAuthCfg := session.AuthConfig{
CookieName: authCfg.CookieName,
CookieSecret: authCfg.CookieSecret,
CookieSecure: authCfg.CookieSecure,
}
errorHandler := session.ErrorHandler{
OnCookieError: func(err error) {
panic(fmt.Errorf("cannot get session: %w", err))
},
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
},
OnTenantError: func(err error) {
panic(fmt.Errorf("cannot list tenants for user: %w", err))
},
}
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
next(w, r)
return
}
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
ctx = context.WithValue(ctx, serverauth.UserContextKey, authResult.User)
ctx = context.WithValue(ctx, serverauth.UserTenantContextKey, &serverauth.UserTenantAccess{
TenantIDs: authResult.TenantIDs,
AuthErrors: authResult.AuthErrors,
})
next(w, r.WithContext(ctx))
// Update session after the handler completes
if _, err := authSvc.UpdateSession(ctx, authResult.Session.ID); err != nil {
panic(fmt.Errorf("cannot update session: %w", err))
}
}
}
func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService {
return GetTenantService(ctx, r.proboSvc, tenantID)
}
func (r *Resolver) AuthzService(ctx context.Context, tenantID gid.TenantID) *authz.TenantAuthzService {
return GetTenantAuthzService(ctx, r.authzSvc, tenantID)
}
func (r *Resolver) AuthService(ctx context.Context, tenantID gid.TenantID) *auth.TenantAuthService {
return GetTenantAuthService(ctx, r.authSvc, tenantID)
return GetTenantService(ctx, r.probo, tenantID)
}
func UnwrapOmittable[T any](field graphql.Omittable[T]) *T {
@@ -439,26 +324,19 @@ func UnwrapOmittable[T any](field graphql.Omittable[T]) *T {
}
func GetTenantService(ctx context.Context, proboSvc *probo.Service, tenantID gid.TenantID) *probo.TenantService {
serverauth.RequireTenantAccess(ctx, tenantID)
return proboSvc.WithTenant(tenantID)
}
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
serverauth.RequireTenantAccess(ctx, tenantID)
return authzSvc.WithTenant(tenantID)
}
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action iam.Action) {
user := connect_v1.UserFromContext(ctx)
apiKey := connect_v1.APIKeyFromContext(ctx)
func GetTenantAuthService(ctx context.Context, authSvc *auth.Service, tenantID gid.TenantID) *auth.TenantAuthService {
serverauth.RequireTenantAccess(ctx, tenantID)
return authSvc.WithTenant(tenantID)
}
var credentialID *gid.GID
if apiKey != nil {
credentialID = &apiKey.ID
}
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action authz.Action) {
user := UserFromContext(ctx)
apiKey := UserAPIKeyFromContext(ctx)
authzSvc := r.AuthzService(ctx, entityID.TenantID())
err := authzSvc.Authorize(ctx, user, apiKey, entityID, action)
err := r.iam.AccessManagementService.Authorize(ctx, user.ID, credentialID, entityID, action)
if err != nil {
panic(err)
}

View File

@@ -34,15 +34,6 @@ type PageInfo {
endCursor: CursorKey
}
# Roles
enum Role {
OWNER
ADMIN
VIEWER
AUDITOR
FULL
}
# Enums
enum OrderDirection
@goModel(model: "go.probo.inc/probo/pkg/page.OrderDirection") {
@@ -83,31 +74,6 @@ enum PeopleKind @goModel(model: "go.probo.inc/probo/pkg/coredata.PeopleKind") {
@goEnum(value: "go.probo.inc/probo/pkg/coredata.PeopleKindServiceAccount")
}
enum InvitationStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.InvitationStatus") {
PENDING
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusPending")
ACCEPTED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusAccepted")
EXPIRED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusExpired")
}
enum MembershipRole
@goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipRole") {
OWNER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleOwner")
ADMIN @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAdmin")
EMPLOYEE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleEmployee")
VIEWER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleViewer")
AUDITOR
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAuditor")
}
enum APIRole @goModel(model: "go.probo.inc/probo/pkg/coredata.APIRole") {
FULL @goEnum(value: "go.probo.inc/probo/pkg/coredata.APIRoleFull")
}
enum DocumentStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
DRAFT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusDraft")
@@ -143,26 +109,6 @@ enum AuditState @goModel(model: "go.probo.inc/probo/pkg/coredata.AuditState") {
OUTDATED @goEnum(value: "go.probo.inc/probo/pkg/coredata.AuditStateOutdated")
}
enum SAMLEnforcementPolicy
@goModel(model: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicy") {
OFF @goEnum(value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyOff")
OPTIONAL
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyOptional"
)
REQUIRED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyRequired"
)
}
enum UserAuthMethod
@goModel(model: "go.probo.inc/probo/pkg/coredata.UserAuthMethod") {
PASSWORD
@goEnum(value: "go.probo.inc/probo/pkg/coredata.UserAuthMethodPassword")
SAML @goEnum(value: "go.probo.inc/probo/pkg/coredata.UserAuthMethodSAML")
}
enum TrustCenterVisibility
@goModel(model: "go.probo.inc/probo/pkg/coredata.TrustCenterVisibility") {
NONE
@@ -226,8 +172,7 @@ enum ObligationStatus
enum ObligationType
@goModel(model: "go.probo.inc/probo/pkg/coredata.ObligationType") {
LEGAL
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ObligationTypeLegal")
LEGAL @goEnum(value: "go.probo.inc/probo/pkg/coredata.ObligationTypeLegal")
CONTRACTUAL
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ObligationTypeContractual")
}
@@ -269,17 +214,11 @@ enum ContinualImprovementPriority
}
enum RightsRequestType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.RightsRequestType"
) {
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestType") {
ACCESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeAccess"
)
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeAccess")
DELETION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion"
)
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion")
PORTABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
@@ -287,21 +226,13 @@ enum RightsRequestType
}
enum RightsRequestState
@goModel(
model: "go.probo.inc/probo/pkg/coredata.RightsRequestState"
) {
TODO
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateTodo"
)
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestState") {
TODO @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateTodo")
IN_PROGRESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateInProgress"
)
DONE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone"
)
DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
}
enum ProcessingActivitySpecialOrCriminalDatum
@@ -429,9 +360,7 @@ enum DataProtectionImpactAssessmentResidualRisk
}
enum ProcessingActivityRole
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ProcessingActivityRole"
) {
@goModel(model: "go.probo.inc/probo/pkg/coredata.ProcessingActivityRole") {
CONTROLLER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ProcessingActivityRoleController"
@@ -443,12 +372,6 @@ enum ProcessingActivityRole
}
# Order Field Enums
enum UserOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.UserOrderField") {
CREATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.UserOrderFieldCreatedAt")
}
enum PeopleOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.PeopleOrderField") {
FULL_NAME
@@ -1110,9 +1033,7 @@ enum ContinualImprovementOrderField
}
enum RightsRequestOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.RightsRequestOrderField"
) {
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestOrderField") {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestOrderFieldCreatedAt"
@@ -1259,57 +1180,7 @@ enum SnapshotOrderField
TYPE @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotOrderFieldType")
}
enum MembershipOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipOrderField") {
FULL_NAME
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MembershipOrderFieldFullName"
)
EMAIL_ADDRESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MembershipOrderFieldEmailAddress"
)
ROLE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipOrderFieldRole")
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MembershipOrderFieldCreatedAt"
)
}
enum InvitationOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.InvitationOrderField") {
FULL_NAME
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldFullName"
)
EMAIL
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldEmail")
ROLE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldRole")
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldCreatedAt"
)
EXPIRES_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldExpiresAt"
)
ACCEPTED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldAcceptedAt"
)
}
# Input Types
input UserOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.UserOrderBy"
) {
direction: OrderDirection!
field: UserOrderField!
}
input PeopleOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.PeopleOrderBy"
@@ -1531,23 +1402,6 @@ input SnapshotOrder
field: SnapshotOrderField!
}
input MembershipOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.MembershipOrderBy"
) {
direction: OrderDirection!
field: MembershipOrderField!
}
input InvitationOrder {
direction: OrderDirection!
field: InvitationOrderField!
}
input InvitationFilter {
statuses: [InvitationStatus!]
}
input DocumentVersionFilter {
status: DocumentStatus
}
@@ -1595,7 +1449,6 @@ input ContinualImprovementFilter {
snapshotId: ID
}
input ProcessingActivityFilter {
snapshotId: ID
}
@@ -1655,23 +1508,6 @@ type Organization implements Node {
headquarterAddress: String
context: OrganizationContext @goField(forceResolver: true)
memberships(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: MembershipOrder
): MembershipConnection! @goField(forceResolver: true)
invitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: InvitationOrder
filter: InvitationFilter
): InvitationConnection! @goField(forceResolver: true)
slackConnections(
first: Int
after: CursorKey
@@ -1874,44 +1710,10 @@ type Organization implements Node {
customDomain: CustomDomain @goField(forceResolver: true)
samlConfigurations: [SAMLConfiguration!]! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type User implements Node {
id: ID!
fullName: String!
email: EmailAddr!
createdAt: Datetime!
updatedAt: Datetime!
}
type Membership implements Node {
id: ID!
userID: ID!
organizationID: ID!
role: MembershipRole!
fullName: String!
emailAddress: EmailAddr!
authMethod: UserAuthMethod! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type Invitation implements Node {
id: ID!
email: EmailAddr!
fullName: String!
role: MembershipRole!
status: InvitationStatus!
expiresAt: Datetime!
acceptedAt: Datetime
createdAt: Datetime!
organization: Organization! @goField(forceResolver: true)
}
type SlackConnection {
id: ID!
channel: String
@@ -2324,7 +2126,8 @@ type StateOfApplicability implements Node {
orderBy: ControlOrder
filter: ControlFilter
): ControlConnection! @goField(forceResolver: true)
availableControls: [AvailableStateOfApplicabilityControl!]! @goField(forceResolver: true)
availableControls: [AvailableStateOfApplicabilityControl!]!
@goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
@@ -2510,8 +2313,10 @@ type ProcessingActivity implements Node {
before: CursorKey
orderBy: VendorOrder
): VendorConnection! @goField(forceResolver: true)
dataProtectionImpactAssessment: DataProtectionImpactAssessment @goField(forceResolver: true)
transferImpactAssessment: TransferImpactAssessment @goField(forceResolver: true)
dataProtectionImpactAssessment: DataProtectionImpactAssessment
@goField(forceResolver: true)
transferImpactAssessment: TransferImpactAssessment
@goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
@@ -2580,15 +2385,6 @@ type Session {
type Viewer {
id: ID!
user: User!
organizations(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: OrganizationOrder
): OrganizationConnection! @goField(forceResolver: true)
signableDocuments(
organizationId: ID!
@@ -2602,17 +2398,6 @@ type Viewer {
signableDocument(id: ID!): SignableDocument @goField(forceResolver: true)
}
# Connection Types
type OrganizationConnection {
edges: [OrganizationEdge!]!
pageInfo: PageInfo!
}
type OrganizationEdge {
cursor: CursorKey!
node: Organization!
}
type TrustCenterConnection {
edges: [TrustCenterEdge!]!
pageInfo: PageInfo!
@@ -2729,34 +2514,6 @@ type TrustCenterFileEdge {
node: TrustCenterFile!
}
type UserConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.UserConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type MembershipConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.MembershipConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [MembershipEdge!]!
pageInfo: PageInfo!
}
type MembershipEdge {
cursor: CursorKey!
node: Membership!
}
type UserEdge {
cursor: CursorKey!
node: User!
}
type PeopleConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.PeopleConnection"
@@ -3126,20 +2883,6 @@ type File {
updatedAt: Datetime!
}
type InvitationConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.InvitationConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [InvitationEdge!]!
pageInfo: PageInfo!
}
type InvitationEdge {
cursor: CursorKey!
node: Invitation!
}
# Root Types
type Query {
node(id: ID!): Node!
@@ -3147,22 +2890,9 @@ type Query {
}
type Mutation {
# Organization mutations
createOrganization(
input: CreateOrganizationInput!
): CreateOrganizationPayload!
updateOrganization(
input: UpdateOrganizationInput!
): UpdateOrganizationPayload!
updateOrganizationContext(
input: UpdateOrganizationContextInput!
): UpdateOrganizationContextPayload!
deleteOrganizationHorizontalLogo(
input: DeleteOrganizationHorizontalLogoInput!
): DeleteOrganizationHorizontalLogoPayload!
deleteOrganization(
input: DeleteOrganizationInput!
): DeleteOrganizationPayload!
updateTrustCenter(input: UpdateTrustCenterInput!): UpdateTrustCenterPayload!
uploadTrustCenterNDA(
input: UploadTrustCenterNDAInput!
@@ -3203,13 +2933,7 @@ type Mutation {
deleteTrustCenterFile(
input: DeleteTrustCenterFileInput!
): DeleteTrustCenterFilePayload!
# User mutations
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload!
acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload!
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload!
removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
updateMembership(input: UpdateMembershipInput!): UpdateMembershipPayload!
# People mutations
createPeople(input: CreatePeopleInput!): CreatePeoplePayload!
updatePeople(input: UpdatePeopleInput!): UpdatePeoplePayload!
@@ -3407,6 +3131,7 @@ type Mutation {
input: CancelSignatureRequestInput!
): CancelSignatureRequestPayload!
signDocument(input: SignDocumentInput!): SignDocumentPayload!
exportDocumentVersionPDF(
input: ExportDocumentVersionPDFInput!
): ExportDocumentVersionPDFPayload!
@@ -3515,25 +3240,6 @@ type Mutation {
deleteCustomDomain(
input: DeleteCustomDomainInput!
): DeleteCustomDomainPayload!
# SAML Configuration mutations (OWNER/ADMIN only)
# Step 1: Initiate domain verification (creates SAML config with unverified domain)
initiateDomainVerification(
input: InitiateDomainVerificationInput!
): InitiateDomainVerificationPayload!
# Step 2: Verify domain ownership via DNS TXT record
verifyDomain(input: VerifyDomainInput!): VerifyDomainPayload!
# Step 3: Configure SAML (only allowed after domain is verified)
createSAMLConfiguration(
input: CreateSAMLConfigurationInput!
): CreateSAMLConfigurationPayload!
updateSAMLConfiguration(
input: UpdateSAMLConfigurationInput!
): UpdateSAMLConfigurationPayload!
deleteSAMLConfiguration(
input: DeleteSAMLConfigurationInput!
): DeleteSAMLConfigurationPayload!
enableSAML(input: EnableSAMLInput!): EnableSAMLPayload!
disableSAML(input: DisableSAMLInput!): DisableSAMLPayload!
}
# Input Types
@@ -3545,34 +3251,11 @@ type GenerateFrameworkStateOfApplicabilityPayload {
data: String!
}
input CreateOrganizationInput {
name: String!
}
input UpdateOrganizationInput {
organizationId: ID!
name: String
description: String @goField(omittable: true)
websiteUrl: String @goField(omittable: true)
email: String @goField(omittable: true)
headquarterAddress: String @goField(omittable: true)
logoFile: Upload
horizontalLogoFile: Upload
}
input UpdateOrganizationContextInput {
organizationId: ID!
summary: String @goField(omittable: true)
}
input DeleteOrganizationHorizontalLogoInput {
organizationId: ID!
}
input DeleteOrganizationInput {
organizationId: ID!
}
input UpdateTrustCenterInput {
trustCenterId: ID!
active: Boolean
@@ -3763,7 +3446,7 @@ input UpdatePeopleInput {
id: ID!
fullName: String
primaryEmailAddress: EmailAddr
additionalEmailAddresses: [EmailAddr!]
additionalEmailAddresses: [EmailAddr!] @goField(omittable: true)
kind: PeopleKind
position: String @goField(omittable: true)
contractStartDate: Datetime @goField(omittable: true)
@@ -3844,7 +3527,6 @@ input DeleteTaskInput {
taskId: ID!
}
input CreateControlMeasureMappingInput {
controlId: ID!
measureId: ID!
@@ -4152,7 +3834,6 @@ input DeleteStateOfApplicabilityInput {
stateOfApplicabilityId: ID!
}
type StateOfApplicabilityControl {
id: ID!
stateOfApplicabilityId: ID!
@@ -4162,13 +3843,19 @@ type StateOfApplicabilityControl {
justification: String
}
type StateOfApplicabilityControlConnection @goModel(model: "go.probo.inc/probo/pkg/server/api/console/v1/types.StateOfApplicabilityControlConnection") {
type StateOfApplicabilityControlConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.StateOfApplicabilityControlConnection"
) {
totalCount: Int!
edges: [StateOfApplicabilityControlEdge!]!
pageInfo: PageInfo!
}
type StateOfApplicabilityControlEdge @goModel(model: "go.probo.inc/probo/pkg/server/api/console/v1/types.StateOfApplicabilityControlEdge") {
type StateOfApplicabilityControlEdge
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.StateOfApplicabilityControlEdge"
) {
cursor: CursorKey!
node: StateOfApplicabilityControl!
}
@@ -4202,38 +3889,6 @@ enum StateOfApplicabilityOrderField
value: "go.probo.inc/probo/pkg/coredata.StateOfApplicabilityOrderFieldCreatedAt"
)
}
input ConfirmEmailInput {
token: String!
}
input InviteUserInput {
organizationId: ID!
email: EmailAddr!
fullName: String!
role: MembershipRole!
createPeople: Boolean!
}
input AcceptInvitationInput {
invitationId: ID!
}
input DeleteInvitationInput {
invitationId: ID!
}
input RemoveMemberInput {
organizationId: ID!
memberId: ID!
}
input UpdateMembershipInput {
organizationId: ID!
memberId: ID!
role: MembershipRole!
}
input CreateControlInput {
frameworkId: ID!
sectionTitle: String!
@@ -4517,13 +4172,6 @@ input DeleteSnapshotInput {
}
# Payload Types
type CreateOrganizationPayload {
organizationEdge: OrganizationEdge!
}
type UpdateOrganizationPayload {
organization: Organization!
}
type UpdateOrganizationContextPayload {
context: OrganizationContext!
@@ -4534,14 +4182,6 @@ type OrganizationContext {
summary: String
}
type DeleteOrganizationHorizontalLogoPayload {
organization: Organization!
}
type DeleteOrganizationPayload {
deletedOrganizationId: ID!
}
type UpdateTrustCenterPayload {
trustCenter: TrustCenter!
}
@@ -4698,7 +4338,6 @@ type DeleteTaskPayload {
deletedTaskId: ID!
}
type CreateControlMeasureMappingPayload {
controlEdge: ControlEdge!
measureEdge: MeasureEdge!
@@ -4906,30 +4545,6 @@ type DeleteStateOfApplicabilityPayload {
deletedStateOfApplicabilityId: ID!
}
type ConfirmEmailPayload {
success: Boolean!
}
type InviteUserPayload {
invitationEdge: InvitationEdge!
}
type AcceptInvitationPayload {
invitation: Invitation!
}
type DeleteInvitationPayload {
deletedInvitationId: ID!
}
type RemoveMemberPayload {
deletedMemberId: ID!
}
type UpdateMembershipPayload {
membership: Membership!
}
input VendorRiskAssessmentOrder {
field: VendorRiskAssessmentOrderField!
direction: OrderDirection!
@@ -5213,7 +4828,7 @@ type Asset implements Node {
before: CursorKey
orderBy: VendorOrder
): VendorConnection! @goField(forceResolver: true)
assetType: AssetType! @goField(forceResolver: true)
assetType: AssetType!
dataTypesStored: String!
organization: Organization! @goField(forceResolver: true)
createdAt: Datetime!
@@ -5513,162 +5128,3 @@ type CreateCustomDomainPayload {
type DeleteCustomDomainPayload {
deletedCustomDomainId: ID!
}
# ============================================
# SAML Configuration Types
# ============================================
type SAMLConfiguration implements Node {
id: ID!
organization: Organization! @goField(forceResolver: true)
emailDomain: String!
enabled: Boolean!
enforcementPolicy: SAMLEnforcementPolicy!
# Domain verification (required before SAML can be configured)
domainVerified: Boolean!
domainVerificationToken: String
domainVerifiedAt: Datetime
# Service Provider metadata (read-only, auto-generated)
spEntityId: String!
spAcsUrl: String!
spMetadataUrl: String! @goField(forceResolver: true)
# Identity Provider configuration
idpEntityId: String!
idpSsoUrl: String!
idpCertificate: String!
idpMetadataUrl: String
# Attribute mapping
attributeEmail: String!
attributeFirstname: String!
attributeLastname: String!
attributeRole: String!
# Auto-signup
autoSignupEnabled: Boolean!
# Test login URL for this configuration
testLoginUrl: String! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
# ============================================
# SAML Configuration Inputs
# ============================================
input CreateSAMLConfigurationInput {
organizationId: ID!
# Email domain this config applies to
emailDomain: String!
# Enforcement policy for this SAML configuration
enforcementPolicy: SAMLEnforcementPolicy!
# SP configuration (optional - auto-generated if not provided)
spCertificate: String
spPrivateKey: String
# IdP configuration - Option 1: Provide metadata XML (recommended for Google Workspace)
# This will automatically extract entityId, ssoUrl, and certificate from the metadata
idpMetadataXml: String
# IdP configuration - Option 2: Provide individual fields manually
# Required if idpMetadataXml is not provided
idpEntityId: String
idpSsoUrl: String
idpCertificate: String
idpMetadataUrl: String
# Attribute mapping (optional, defaults provided)
attributeEmail: String
attributeFirstname: String
attributeLastname: String
attributeRole: String
autoSignupEnabled: Boolean
}
input UpdateSAMLConfigurationInput {
id: ID!
enabled: Boolean
enforcementPolicy: SAMLEnforcementPolicy
spCertificate: String
spPrivateKey: String
idpEntityId: String
idpSsoUrl: String
idpCertificate: String
idpMetadataUrl: String
attributeEmail: String
attributeFirstname: String
attributeLastname: String
attributeRole: String
autoSignupEnabled: Boolean
}
# ============================================
# Domain Verification Inputs
# ============================================
input InitiateDomainVerificationInput {
organizationId: ID!
emailDomain: String!
}
input VerifyDomainInput {
id: ID!
}
input DeleteSAMLConfigurationInput {
id: ID!
}
input EnableSAMLInput {
id: ID!
}
input DisableSAMLInput {
id: ID!
}
# ============================================
# SAML Configuration Payloads
# ============================================
type InitiateDomainVerificationPayload {
samlConfiguration: SAMLConfiguration!
# The TXT record value that needs to be added to DNS
# Format: probo-verification={token}
dnsRecord: String!
}
type VerifyDomainPayload {
samlConfiguration: SAMLConfiguration!
verified: Boolean!
}
type CreateSAMLConfigurationPayload {
samlConfiguration: SAMLConfiguration!
}
type UpdateSAMLConfigurationPayload {
samlConfiguration: SAMLConfiguration!
}
type DeleteSAMLConfigurationPayload {
deletedSAMLConfigurationId: ID!
}
type EnableSAMLPayload {
samlConfiguration: SAMLConfiguration!
}
type DisableSAMLPayload {
samlConfiguration: SAMLConfiguration!
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,33 +16,8 @@ package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
)
type (
OrganizationOrderBy OrderBy[coredata.OrganizationOrderField]
)
func NewOrganizationConnection(page *page.Page[*coredata.Organization, coredata.OrganizationOrderField]) *OrganizationConnection {
var edges = make([]*OrganizationEdge, len(page.Data))
for i := range edges {
edges[i] = NewOrganizationEdge(page.Data[i], page.Cursor.OrderBy.Field)
}
return &OrganizationConnection{
Edges: edges,
PageInfo: NewPageInfo(page),
}
}
func NewOrganizationEdge(o *coredata.Organization, orderBy coredata.OrganizationOrderField) *OrganizationEdge {
return &OrganizationEdge{
Cursor: o.CursorKey(orderBy),
Node: NewOrganization(o),
}
}
func NewOrganization(o *coredata.Organization) *Organization {
return &Organization{
ID: o.ID,

View File

@@ -64,7 +64,6 @@ func NewPeopleEdge(p *coredata.People, orderBy coredata.PeopleOrderField) *Peopl
}
func NewPeople(p *coredata.People) *People {
return &People{
ID: p.ID,
FullName: p.FullName,

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
)
func NewSAMLConfigurationWithURLs(c *coredata.SAMLConfiguration, spEntityID, spAcsURL string) *SAMLConfiguration {
return &SAMLConfiguration{
ID: c.ID,
EmailDomain: c.EmailDomain,
Enabled: c.Enabled,
EnforcementPolicy: c.EnforcementPolicy,
DomainVerified: c.DomainVerified,
DomainVerificationToken: c.DomainVerificationToken,
DomainVerifiedAt: c.DomainVerifiedAt,
SpEntityID: spEntityID,
SpAcsURL: spAcsURL,
IdpEntityID: c.IdPEntityID,
IdpSsoURL: c.IdPSsoURL,
IdpCertificate: c.IdPCertificate,
IdpMetadataURL: c.IdPMetadataURL,
AttributeEmail: c.AttributeEmail,
AttributeFirstname: c.AttributeFirstname,
AttributeLastname: c.AttributeLastname,
AttributeRole: c.AttributeRole,
AutoSignupEnabled: c.AutoSignupEnabled,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}

View File

@@ -65,7 +65,13 @@ func NewStateOfApplicabilityEdge(soa *coredata.StateOfApplicability, orderBy cor
func NewStateOfApplicability(soa *coredata.StateOfApplicability) *StateOfApplicability {
return &StateOfApplicability{
ID: soa.ID,
ID: soa.ID,
Organization: &Organization{
ID: soa.OrganizationID,
},
Owner: &People{
ID: soa.OwnerID,
},
Name: soa.Name,
SourceID: soa.SourceID,
SnapshotID: soa.SnapshotID,

View File

@@ -61,7 +61,7 @@ func NewTaskEdge(t *coredata.Task, orderBy coredata.TaskOrderField) *TaskEdge {
}
func NewTask(t *coredata.Task) *Task {
return &Task{
node := &Task{
ID: t.ID,
Name: t.Name,
Description: t.Description,
@@ -71,4 +71,12 @@ func NewTask(t *coredata.Task) *Task {
UpdatedAt: t.UpdatedAt,
Deadline: t.Deadline,
}
if t.MeasureID != nil {
node.Measure = &Measure{
ID: *t.MeasureID,
}
}
return node
}

View File

@@ -3,10 +3,6 @@
package types
import (
"bytes"
"fmt"
"io"
"strconv"
"time"
"github.com/99designs/gqlgen/graphql"
@@ -21,14 +17,6 @@ type Node interface {
GetID() gid.GID
}
type AcceptInvitationInput struct {
InvitationID gid.GID `json:"invitationId"`
}
type AcceptInvitationPayload struct {
Invitation *Invitation `json:"invitation"`
}
type AssessVendorInput struct {
ID gid.GID `json:"id"`
WebsiteURL string `json:"websiteUrl"`
@@ -150,14 +138,6 @@ type CancelSignatureRequestPayload struct {
DeletedDocumentVersionSignatureID gid.GID `json:"deletedDocumentVersionSignatureId"`
}
type ConfirmEmailInput struct {
Token string `json:"token"`
}
type ConfirmEmailPayload struct {
Success bool `json:"success"`
}
type ContinualImprovement struct {
ID gid.GID `json:"id"`
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
@@ -441,14 +421,6 @@ type CreateObligationPayload struct {
ObligationEdge *ObligationEdge `json:"obligationEdge"`
}
type CreateOrganizationInput struct {
Name string `json:"name"`
}
type CreateOrganizationPayload struct {
OrganizationEdge *OrganizationEdge `json:"organizationEdge"`
}
type CreatePeopleInput struct {
OrganizationID gid.GID `json:"organizationId"`
FullName string `json:"fullName"`
@@ -555,28 +527,6 @@ type CreateRiskPayload struct {
RiskEdge *RiskEdge `json:"riskEdge"`
}
type CreateSAMLConfigurationInput struct {
OrganizationID gid.GID `json:"organizationId"`
EmailDomain string `json:"emailDomain"`
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
SpCertificate *string `json:"spCertificate,omitempty"`
SpPrivateKey *string `json:"spPrivateKey,omitempty"`
IdpMetadataXML *string `json:"idpMetadataXml,omitempty"`
IdpEntityID *string `json:"idpEntityId,omitempty"`
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
IdpCertificate *string `json:"idpCertificate,omitempty"`
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
AttributeEmail *string `json:"attributeEmail,omitempty"`
AttributeFirstname *string `json:"attributeFirstname,omitempty"`
AttributeLastname *string `json:"attributeLastname,omitempty"`
AttributeRole *string `json:"attributeRole,omitempty"`
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
}
type CreateSAMLConfigurationPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
}
type CreateSnapshotInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name string `json:"name"`
@@ -936,14 +886,6 @@ type DeleteFrameworkPayload struct {
DeletedFrameworkID gid.GID `json:"deletedFrameworkId"`
}
type DeleteInvitationInput struct {
InvitationID gid.GID `json:"invitationId"`
}
type DeleteInvitationPayload struct {
DeletedInvitationID gid.GID `json:"deletedInvitationId"`
}
type DeleteMeasureInput struct {
MeasureID gid.GID `json:"measureId"`
}
@@ -976,22 +918,6 @@ type DeleteObligationPayload struct {
DeletedObligationID gid.GID `json:"deletedObligationId"`
}
type DeleteOrganizationHorizontalLogoInput struct {
OrganizationID gid.GID `json:"organizationId"`
}
type DeleteOrganizationHorizontalLogoPayload struct {
Organization *Organization `json:"organization"`
}
type DeleteOrganizationInput struct {
OrganizationID gid.GID `json:"organizationId"`
}
type DeleteOrganizationPayload struct {
DeletedOrganizationID gid.GID `json:"deletedOrganizationId"`
}
type DeletePeopleInput struct {
PeopleID gid.GID `json:"peopleId"`
}
@@ -1054,14 +980,6 @@ type DeleteRiskPayload struct {
DeletedRiskID gid.GID `json:"deletedRiskId"`
}
type DeleteSAMLConfigurationInput struct {
ID gid.GID `json:"id"`
}
type DeleteSAMLConfigurationPayload struct {
DeletedSAMLConfigurationID gid.GID `json:"deletedSAMLConfigurationId"`
}
type DeleteSnapshotInput struct {
SnapshotID gid.GID `json:"snapshotId"`
}
@@ -1185,14 +1103,6 @@ type DeleteVendorServicePayload struct {
DeletedVendorServiceID gid.GID `json:"deletedVendorServiceId"`
}
type DisableSAMLInput struct {
ID gid.GID `json:"id"`
}
type DisableSAMLPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
}
type Document struct {
ID gid.GID `json:"id"`
Title string `json:"title"`
@@ -1268,14 +1178,6 @@ type DocumentVersionSignatureOrder struct {
Direction page.OrderDirection `json:"direction"`
}
type EnableSAMLInput struct {
ID gid.GID `json:"id"`
}
type EnableSAMLPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
}
type Evidence struct {
ID gid.GID `json:"id"`
Size int `json:"size"`
@@ -1443,57 +1345,6 @@ type ImportMeasurePayload struct {
MeasureEdges []*MeasureEdge `json:"measureEdges"`
}
type InitiateDomainVerificationInput struct {
OrganizationID gid.GID `json:"organizationId"`
EmailDomain string `json:"emailDomain"`
}
type InitiateDomainVerificationPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
DNSRecord string `json:"dnsRecord"`
}
type Invitation struct {
ID gid.GID `json:"id"`
Email mail.Addr `json:"email"`
FullName string `json:"fullName"`
Role coredata.MembershipRole `json:"role"`
Status coredata.InvitationStatus `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Organization *Organization `json:"organization"`
}
func (Invitation) IsNode() {}
func (this Invitation) GetID() gid.GID { return this.ID }
type InvitationEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Invitation `json:"node"`
}
type InvitationFilter struct {
Statuses []coredata.InvitationStatus `json:"statuses,omitempty"`
}
type InvitationOrder struct {
Direction page.OrderDirection `json:"direction"`
Field coredata.InvitationOrderField `json:"field"`
}
type InviteUserInput struct {
OrganizationID gid.GID `json:"organizationId"`
Email mail.Addr `json:"email"`
FullName string `json:"fullName"`
Role coredata.MembershipRole `json:"role"`
CreatePeople bool `json:"createPeople"`
}
type InviteUserPayload struct {
InvitationEdge *InvitationEdge `json:"invitationEdge"`
}
type Measure struct {
ID gid.GID `json:"id"`
Category string `json:"category"`
@@ -1540,26 +1391,6 @@ type MeetingEdge struct {
Node *Meeting `json:"node"`
}
type Membership struct {
ID gid.GID `json:"id"`
UserID gid.GID `json:"userID"`
OrganizationID gid.GID `json:"organizationID"`
Role coredata.MembershipRole `json:"role"`
FullName string `json:"fullName"`
EmailAddress mail.Addr `json:"emailAddress"`
AuthMethod coredata.UserAuthMethod `json:"authMethod"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Membership) IsNode() {}
func (this Membership) GetID() gid.GID { return this.ID }
type MembershipEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Membership `json:"node"`
}
type Mutation struct {
}
@@ -1634,8 +1465,6 @@ type Organization struct {
Email *string `json:"email,omitempty"`
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
Context *OrganizationContext `json:"context,omitempty"`
Memberships *MembershipConnection `json:"memberships"`
Invitations *InvitationConnection `json:"invitations"`
SlackConnections *SlackConnectionConnection `json:"slackConnections"`
Frameworks *FrameworkConnection `json:"frameworks"`
Controls *ControlConnection `json:"controls"`
@@ -1661,7 +1490,6 @@ type Organization struct {
TrustCenterFiles *TrustCenterFileConnection `json:"trustCenterFiles"`
TrustCenter *TrustCenter `json:"trustCenter,omitempty"`
CustomDomain *CustomDomain `json:"customDomain,omitempty"`
SamlConfigurations []*SAMLConfiguration `json:"samlConfigurations"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -1669,21 +1497,11 @@ type Organization struct {
func (Organization) IsNode() {}
func (this Organization) GetID() gid.GID { return this.ID }
type OrganizationConnection struct {
Edges []*OrganizationEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type OrganizationContext struct {
OrganizationID gid.GID `json:"organizationId"`
Summary *string `json:"summary,omitempty"`
}
type OrganizationEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Organization `json:"node"`
}
type OrganizationOrder struct {
Direction page.OrderDirection `json:"direction"`
Field coredata.OrganizationOrderField `json:"field"`
@@ -1777,15 +1595,6 @@ type PublishDocumentVersionPayload struct {
type Query struct {
}
type RemoveMemberInput struct {
OrganizationID gid.GID `json:"organizationId"`
MemberID gid.GID `json:"memberId"`
}
type RemoveMemberPayload struct {
DeletedMemberID gid.GID `json:"deletedMemberId"`
}
type Report struct {
ID gid.GID `json:"id"`
ObjectKey string `json:"objectKey"`
@@ -1880,35 +1689,6 @@ type RiskFilter struct {
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
}
type SAMLConfiguration struct {
ID gid.GID `json:"id"`
Organization *Organization `json:"organization"`
EmailDomain string `json:"emailDomain"`
Enabled bool `json:"enabled"`
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
DomainVerified bool `json:"domainVerified"`
DomainVerificationToken *string `json:"domainVerificationToken,omitempty"`
DomainVerifiedAt *time.Time `json:"domainVerifiedAt,omitempty"`
SpEntityID string `json:"spEntityId"`
SpAcsURL string `json:"spAcsUrl"`
SpMetadataURL string `json:"spMetadataUrl"`
IdpEntityID string `json:"idpEntityId"`
IdpSsoURL string `json:"idpSsoUrl"`
IdpCertificate string `json:"idpCertificate"`
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
AttributeEmail string `json:"attributeEmail"`
AttributeFirstname string `json:"attributeFirstname"`
AttributeLastname string `json:"attributeLastname"`
AttributeRole string `json:"attributeRole"`
AutoSignupEnabled bool `json:"autoSignupEnabled"`
TestLoginURL string `json:"testLoginUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SAMLConfiguration) IsNode() {}
func (this SAMLConfiguration) GetID() gid.GID { return this.ID }
type SendSigningNotificationsInput struct {
OrganizationID gid.GID `json:"organizationId"`
}
@@ -2289,16 +2069,6 @@ type UpdateMeetingPayload struct {
Meeting *Meeting `json:"meeting"`
}
type UpdateMembershipInput struct {
OrganizationID gid.GID `json:"organizationId"`
MemberID gid.GID `json:"memberId"`
Role coredata.MembershipRole `json:"role"`
}
type UpdateMembershipPayload struct {
Membership *Membership `json:"membership"`
}
type UpdateNonconformityInput struct {
ID gid.GID `json:"id"`
ReferenceID *string `json:"referenceId,omitempty"`
@@ -2344,30 +2114,15 @@ type UpdateOrganizationContextPayload struct {
Context *OrganizationContext `json:"context"`
}
type UpdateOrganizationInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name *string `json:"name,omitempty"`
Description graphql.Omittable[*string] `json:"description,omitempty"`
WebsiteURL graphql.Omittable[*string] `json:"websiteUrl,omitempty"`
Email graphql.Omittable[*string] `json:"email,omitempty"`
HeadquarterAddress graphql.Omittable[*string] `json:"headquarterAddress,omitempty"`
LogoFile *graphql.Upload `json:"logoFile,omitempty"`
HorizontalLogoFile *graphql.Upload `json:"horizontalLogoFile,omitempty"`
}
type UpdateOrganizationPayload struct {
Organization *Organization `json:"organization"`
}
type UpdatePeopleInput struct {
ID gid.GID `json:"id"`
FullName *string `json:"fullName,omitempty"`
PrimaryEmailAddress *mail.Addr `json:"primaryEmailAddress,omitempty"`
AdditionalEmailAddresses []mail.Addr `json:"additionalEmailAddresses,omitempty"`
Kind *coredata.PeopleKind `json:"kind,omitempty"`
Position graphql.Omittable[*string] `json:"position,omitempty"`
ContractStartDate graphql.Omittable[*time.Time] `json:"contractStartDate,omitempty"`
ContractEndDate graphql.Omittable[*time.Time] `json:"contractEndDate,omitempty"`
ID gid.GID `json:"id"`
FullName *string `json:"fullName,omitempty"`
PrimaryEmailAddress *mail.Addr `json:"primaryEmailAddress,omitempty"`
AdditionalEmailAddresses graphql.Omittable[[]mail.Addr] `json:"additionalEmailAddresses,omitempty"`
Kind *coredata.PeopleKind `json:"kind,omitempty"`
Position graphql.Omittable[*string] `json:"position,omitempty"`
ContractStartDate graphql.Omittable[*time.Time] `json:"contractStartDate,omitempty"`
ContractEndDate graphql.Omittable[*time.Time] `json:"contractEndDate,omitempty"`
}
type UpdatePeoplePayload struct {
@@ -2435,27 +2190,6 @@ type UpdateRiskPayload struct {
Risk *Risk `json:"risk"`
}
type UpdateSAMLConfigurationInput struct {
ID gid.GID `json:"id"`
Enabled *bool `json:"enabled,omitempty"`
EnforcementPolicy *coredata.SAMLEnforcementPolicy `json:"enforcementPolicy,omitempty"`
SpCertificate *string `json:"spCertificate,omitempty"`
SpPrivateKey *string `json:"spPrivateKey,omitempty"`
IdpEntityID *string `json:"idpEntityId,omitempty"`
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
IdpCertificate *string `json:"idpCertificate,omitempty"`
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
AttributeEmail *string `json:"attributeEmail,omitempty"`
AttributeFirstname *string `json:"attributeFirstname,omitempty"`
AttributeLastname *string `json:"attributeLastname,omitempty"`
AttributeRole *string `json:"attributeRole,omitempty"`
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
}
type UpdateSAMLConfigurationPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
}
type UpdateStateOfApplicabilityInput struct {
ID gid.GID `json:"id"`
Name *string `json:"name,omitempty"`
@@ -2676,22 +2410,6 @@ type UploadVendorDataPrivacyAgreementPayload struct {
VendorDataPrivacyAgreement *VendorDataPrivacyAgreement `json:"vendorDataPrivacyAgreement"`
}
type User struct {
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
Email mail.Addr `json:"email"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (User) IsNode() {}
func (this User) GetID() gid.GID { return this.ID }
type UserEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *User `json:"node"`
}
type Vendor struct {
ID gid.GID `json:"id"`
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
@@ -2867,80 +2585,8 @@ type VendorServiceEdge struct {
Node *VendorService `json:"node"`
}
type VerifyDomainInput struct {
ID gid.GID `json:"id"`
}
type VerifyDomainPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
Verified bool `json:"verified"`
}
type Viewer struct {
ID gid.GID `json:"id"`
User *User `json:"user"`
Organizations *OrganizationConnection `json:"organizations"`
SignableDocuments *SignableDocumentConnection `json:"signableDocuments"`
SignableDocument *SignableDocument `json:"signableDocument,omitempty"`
}
type Role string
const (
RoleOwner Role = "OWNER"
RoleAdmin Role = "ADMIN"
RoleViewer Role = "VIEWER"
RoleAuditor Role = "AUDITOR"
RoleFull Role = "FULL"
)
var AllRole = []Role{
RoleOwner,
RoleAdmin,
RoleViewer,
RoleAuditor,
RoleFull,
}
func (e Role) IsValid() bool {
switch e {
case RoleOwner, RoleAdmin, RoleViewer, RoleAuditor, RoleFull:
return true
}
return false
}
func (e Role) String() string {
return string(e)
}
func (e *Role) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = Role(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid Role", str)
}
return nil
}
func (e Role) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *Role) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e Role) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/iam"
)
func RecoveryMiddleware(logger *log.Logger) func(mcp.MethodHandler) mcp.MethodHandler {
@@ -47,14 +47,14 @@ func convertPanicToError(ctx context.Context, logger *log.Logger, panicValue any
return fmt.Errorf("internal server error")
}
var tenantAccessErr *authz.TenantAccessError
var tenantAccessErr *iam.TenantAccessError
if errTyped, ok := panicValue.(error); ok && errors.As(errTyped, &tenantAccessErr) {
return fmt.Errorf("not authorized: %s", tenantAccessErr.Message)
}
var permissionDeniedErr *authz.PermissionDeniedError
var permissionDeniedErr *iam.ErrInsufficientPermissions
if errTyped, ok := panicValue.(error); ok && errors.As(errTyped, &permissionDeniedErr) {
return fmt.Errorf("permission denied: %s", permissionDeniedErr.Message)
return fmt.Errorf("permission denied: %s", permissionDeniedErr.Error())
}
if err, ok := panicValue.(error); ok {

View File

@@ -17,18 +17,15 @@ package mcp_v1
import (
"net/http"
"errors"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
serverauth "go.probo.inc/probo/pkg/server/auth"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
)
// WithMCPAuth wraps an HTTP handler with MCP authentication middleware
// It authenticates using API keys from the Authorization header
func WithMCPAuth(
func RequireAPIKeyHandler(
logger *log.Logger,
authSvc *auth.Service,
authzSvc *authz.Service,
next http.Handler,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -43,27 +40,19 @@ func WithMCPAuth(
log.String("path", r.URL.Path),
)
// Authenticate using API key from shared function
authCtx := serverauth.AuthenticateWithAPIKey(ctx, r, authSvc, authzSvc)
if authCtx == nil {
logger.WarnCtx(ctx, "MCP auth: authentication required",
log.String("correlation_id", correlationID),
)
http.Error(w, "authentication required", http.StatusUnauthorized)
apiKey := connect_v1.APIKeyFromContext(ctx)
identity := connect_v1.UserFromContext(ctx)
if identity == nil {
httpserver.RenderError(w, http.StatusUnauthorized, errors.New("authentication required"))
return
}
user := serverauth.UserFromContext(authCtx)
userAPIKey := serverauth.UserAPIKeyFromContext(authCtx)
tenantAccess := serverauth.UserTenantAccessFromContext(authCtx)
logger.InfoCtx(authCtx, "MCP authentication successful",
logger.InfoCtx(ctx, "MCP authentication successful",
log.String("correlation_id", correlationID),
log.String("user_id", user.ID.String()),
log.String("api_key_id", userAPIKey.ID.String()),
log.Int("accessible_tenants", len(tenantAccess.TenantIDs)),
log.String("identity_id", identity.ID.String()),
log.String("api_key_id", apiKey.ID.String()),
)
next.ServeHTTP(w, r.WithContext(authCtx))
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -6,39 +6,32 @@ import (
"context"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
serverauth "go.probo.inc/probo/pkg/server/auth"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
)
type Resolver struct {
proboSvc *probo.Service
authSvc *auth.Service
authzSvc *authz.Service
iamSvc *iam.Service
logger *log.Logger
}
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action authz.Action) {
user := serverauth.UserFromContext(ctx)
apiKey := serverauth.UserAPIKeyFromContext(ctx)
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action iam.Action) {
user := connect_v1.UserFromContext(ctx)
apiKey := connect_v1.APIKeyFromContext(ctx)
if user == nil {
panic(&authz.TenantAccessError{Message: "authentication required"})
panic(&iam.TenantAccessError{Message: "authentication required"})
}
authzSvc := r.AuthzService(ctx, entityID.TenantID())
err := authzSvc.Authorize(ctx, user, apiKey, entityID, action)
var credentialID *gid.GID
if apiKey != nil {
credentialID = &apiKey.ID
}
err := r.iamSvc.AccessManagementService.Authorize(ctx, user.ID, credentialID, entityID, action)
if err != nil {
panic(err)
}
}
func (r *Resolver) AuthzService(ctx context.Context, tenantID gid.TenantID) *authz.TenantAuthzService {
return GetTenantAuthzService(ctx, r.authzSvc, tenantID)
}
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
serverauth.RequireTenantAccess(ctx, tenantID)
return authzSvc.WithTenant(tenantID)
}

View File

@@ -9,24 +9,21 @@ import (
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
"go.probo.inc/probo/pkg/server/api/mcp/v1/types"
serverauth "go.probo.inc/probo/pkg/server/auth"
)
// ListOrganizationsTool handles the listOrganizations tool
// List all organizations the user has access to
func (r *Resolver) ListOrganizationsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListOrganizationsInput) (*mcp.CallToolResult, types.ListOrganizationsOutput, error) {
user := serverauth.UserFromContext(ctx)
if user == nil {
return nil, types.ListOrganizationsOutput{}, fmt.Errorf("authentication required")
}
user := connect_v1.UserFromContext(ctx)
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
organizations, err := r.iamSvc.AccountService.ListOrganizations(ctx, user.ID)
if err != nil {
return nil, types.ListOrganizationsOutput{}, fmt.Errorf("failed to list organizations: %w", err)
}
@@ -45,7 +42,7 @@ func (r *Resolver) ListOrganizationsTool(ctx context.Context, req *mcp.CallToolR
// ListVendorsTool handles the listVendors tool
// List all vendors for the organization
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListVendors)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListVendors)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -78,7 +75,7 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
// AddVendorTool handles the addVendor tool
// Add a new vendor to the organization
func (r *Resolver) AddVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddVendorInput) (*mcp.CallToolResult, types.AddVendorOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateAsset)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateAsset)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -122,7 +119,7 @@ func (r *Resolver) UpdateVendorTool(ctx context.Context, req *mcp.CallToolReques
}
func (r *Resolver) ListPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListPeopleInput) (*mcp.CallToolResult, types.ListPeopleOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListPeople)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListPeople)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -153,7 +150,7 @@ func (r *Resolver) ListPeopleTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetPeopleInput) (*mcp.CallToolResult, types.GetPeopleOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -168,7 +165,7 @@ func (r *Resolver) GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) AddPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddPeopleInput) (*mcp.CallToolResult, types.AddPeopleOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreatePeople)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreatePeople)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -200,7 +197,7 @@ func (r *Resolver) AddPeopleTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) UpdatePeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdatePeopleInput) (*mcp.CallToolResult, types.UpdatePeopleOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdatePeople)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdatePeople)
svc := r.ProboService(ctx, input.ID)
@@ -227,7 +224,7 @@ func (r *Resolver) UpdatePeopleTool(ctx context.Context, req *mcp.CallToolReques
}
func (r *Resolver) ListRisksTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRisksInput) (*mcp.CallToolResult, types.ListRisksOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListRisks)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListRisks)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -258,7 +255,7 @@ func (r *Resolver) ListRisksTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) GetRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetRiskInput) (*mcp.CallToolResult, types.GetRiskOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -273,7 +270,7 @@ func (r *Resolver) GetRiskTool(ctx context.Context, req *mcp.CallToolRequest, in
}
func (r *Resolver) AddRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddRiskInput) (*mcp.CallToolResult, types.AddRiskOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateRisk)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateRisk)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -301,7 +298,7 @@ func (r *Resolver) AddRiskTool(ctx context.Context, req *mcp.CallToolRequest, in
}
func (r *Resolver) UpdateRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateRiskInput) (*mcp.CallToolResult, types.UpdateRiskOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateRisk)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateRisk)
svc := r.ProboService(ctx, input.ID)
@@ -330,7 +327,7 @@ func (r *Resolver) UpdateRiskTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) ListMeasuresTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListMeasuresInput) (*mcp.CallToolResult, types.ListMeasuresOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListMeasures)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListMeasures)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -361,7 +358,7 @@ func (r *Resolver) ListMeasuresTool(ctx context.Context, req *mcp.CallToolReques
}
func (r *Resolver) GetMeasureTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetMeasureInput) (*mcp.CallToolResult, types.GetMeasureOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -376,7 +373,7 @@ func (r *Resolver) GetMeasureTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) AddMeasureTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddMeasureInput) (*mcp.CallToolResult, types.AddMeasureOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateMeasure)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateMeasure)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -399,7 +396,7 @@ func (r *Resolver) AddMeasureTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) UpdateMeasureTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateMeasureInput) (*mcp.CallToolResult, types.UpdateMeasureOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateMeasure)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateMeasure)
svc := r.ProboService(ctx, input.ID)
@@ -423,7 +420,7 @@ func (r *Resolver) UpdateMeasureTool(ctx context.Context, req *mcp.CallToolReque
}
func (r *Resolver) ListFrameworksTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListFrameworksInput) (*mcp.CallToolResult, types.ListFrameworksOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListFrameworks)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListFrameworks)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -449,7 +446,7 @@ func (r *Resolver) ListFrameworksTool(ctx context.Context, req *mcp.CallToolRequ
}
func (r *Resolver) GetFrameworkTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetFrameworkInput) (*mcp.CallToolResult, types.GetFrameworkOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -464,7 +461,7 @@ func (r *Resolver) GetFrameworkTool(ctx context.Context, req *mcp.CallToolReques
}
func (r *Resolver) AddFrameworkTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddFrameworkInput) (*mcp.CallToolResult, types.AddFrameworkOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateFramework)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateFramework)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -486,7 +483,7 @@ func (r *Resolver) AddFrameworkTool(ctx context.Context, req *mcp.CallToolReques
}
func (r *Resolver) UpdateFrameworkTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateFrameworkInput) (*mcp.CallToolResult, types.UpdateFrameworkOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateFramework)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateFramework)
svc := r.ProboService(ctx, input.ID)
@@ -508,7 +505,7 @@ func (r *Resolver) UpdateFrameworkTool(ctx context.Context, req *mcp.CallToolReq
}
func (r *Resolver) ListAssetsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAssetsInput) (*mcp.CallToolResult, types.ListAssetsOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListAssets)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListAssets)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -539,7 +536,7 @@ func (r *Resolver) ListAssetsTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) GetAssetTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAssetInput) (*mcp.CallToolResult, types.GetAssetOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -554,7 +551,7 @@ func (r *Resolver) GetAssetTool(ctx context.Context, req *mcp.CallToolRequest, i
}
func (r *Resolver) AddAssetTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddAssetInput) (*mcp.CallToolResult, types.AddAssetOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateAsset)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateAsset)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -580,7 +577,7 @@ func (r *Resolver) AddAssetTool(ctx context.Context, req *mcp.CallToolRequest, i
}
func (r *Resolver) UpdateAssetTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateAssetInput) (*mcp.CallToolResult, types.UpdateAssetOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateAsset)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateAsset)
svc := r.ProboService(ctx, input.ID)
@@ -606,7 +603,7 @@ func (r *Resolver) UpdateAssetTool(ctx context.Context, req *mcp.CallToolRequest
}
func (r *Resolver) ListDataTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListDataInput) (*mcp.CallToolResult, types.ListDataOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListData)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListData)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -637,7 +634,7 @@ func (r *Resolver) ListDataTool(ctx context.Context, req *mcp.CallToolRequest, i
}
func (r *Resolver) GetDatumTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDatumInput) (*mcp.CallToolResult, types.GetDatumOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -652,7 +649,7 @@ func (r *Resolver) GetDatumTool(ctx context.Context, req *mcp.CallToolRequest, i
}
func (r *Resolver) AddDatumTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddDatumInput) (*mcp.CallToolResult, types.AddDatumOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateDatum)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateDatum)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -676,7 +673,7 @@ func (r *Resolver) AddDatumTool(ctx context.Context, req *mcp.CallToolRequest, i
}
func (r *Resolver) UpdateDatumTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDatumInput) (*mcp.CallToolResult, types.UpdateDatumOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateDatum)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateDatum)
svc := r.ProboService(ctx, input.ID)
@@ -700,7 +697,7 @@ func (r *Resolver) UpdateDatumTool(ctx context.Context, req *mcp.CallToolRequest
}
func (r *Resolver) ListNonconformitiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListNonconformitiesInput) (*mcp.CallToolResult, types.ListNonconformitiesOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListNonconformities)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListNonconformities)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -731,7 +728,7 @@ func (r *Resolver) ListNonconformitiesTool(ctx context.Context, req *mcp.CallToo
}
func (r *Resolver) GetNonconformityTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetNonconformityInput) (*mcp.CallToolResult, types.GetNonconformityOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -746,7 +743,7 @@ func (r *Resolver) GetNonconformityTool(ctx context.Context, req *mcp.CallToolRe
}
func (r *Resolver) AddNonconformityTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddNonconformityInput) (*mcp.CallToolResult, types.AddNonconformityOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateNonconformity)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateNonconformity)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -776,7 +773,7 @@ func (r *Resolver) AddNonconformityTool(ctx context.Context, req *mcp.CallToolRe
}
func (r *Resolver) UpdateNonconformityTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateNonconformityInput) (*mcp.CallToolResult, types.UpdateNonconformityOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateNonconformity)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateNonconformity)
svc := r.ProboService(ctx, input.ID)
@@ -806,7 +803,7 @@ func (r *Resolver) UpdateNonconformityTool(ctx context.Context, req *mcp.CallToo
}
func (r *Resolver) ListObligationsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListObligationsInput) (*mcp.CallToolResult, types.ListObligationsOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListObligations)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListObligations)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -837,7 +834,7 @@ func (r *Resolver) ListObligationsTool(ctx context.Context, req *mcp.CallToolReq
}
func (r *Resolver) GetObligationTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetObligationInput) (*mcp.CallToolResult, types.GetObligationOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -852,7 +849,7 @@ func (r *Resolver) GetObligationTool(ctx context.Context, req *mcp.CallToolReque
}
func (r *Resolver) AddObligationTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddObligationInput) (*mcp.CallToolResult, types.AddObligationOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateObligation)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateObligation)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -882,7 +879,7 @@ func (r *Resolver) AddObligationTool(ctx context.Context, req *mcp.CallToolReque
}
func (r *Resolver) UpdateObligationTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateObligationInput) (*mcp.CallToolResult, types.UpdateObligationOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateObligation)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateObligation)
svc := r.ProboService(ctx, input.ID)
@@ -912,7 +909,7 @@ func (r *Resolver) UpdateObligationTool(ctx context.Context, req *mcp.CallToolRe
}
func (r *Resolver) ListContinualImprovementsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListContinualImprovementsInput) (*mcp.CallToolResult, types.ListContinualImprovementsOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListContinualImprovements)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListContinualImprovements)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -943,7 +940,7 @@ func (r *Resolver) ListContinualImprovementsTool(ctx context.Context, req *mcp.C
}
func (r *Resolver) GetContinualImprovementTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetContinualImprovementInput) (*mcp.CallToolResult, types.GetContinualImprovementOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -958,7 +955,7 @@ func (r *Resolver) GetContinualImprovementTool(ctx context.Context, req *mcp.Cal
}
func (r *Resolver) AddContinualImprovementTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddContinualImprovementInput) (*mcp.CallToolResult, types.AddContinualImprovementOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateContinualImprovement)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateContinualImprovement)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -985,7 +982,7 @@ func (r *Resolver) AddContinualImprovementTool(ctx context.Context, req *mcp.Cal
}
func (r *Resolver) UpdateContinualImprovementTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateContinualImprovementInput) (*mcp.CallToolResult, types.UpdateContinualImprovementOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateContinualImprovement)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateContinualImprovement)
svc := r.ProboService(ctx, input.ID)
@@ -1012,7 +1009,7 @@ func (r *Resolver) UpdateContinualImprovementTool(ctx context.Context, req *mcp.
}
func (r *Resolver) ListAuditsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAuditsInput) (*mcp.CallToolResult, types.ListAuditsOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListAudits)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListAudits)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -1038,7 +1035,7 @@ func (r *Resolver) ListAuditsTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) GetAuditTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAuditInput) (*mcp.CallToolResult, types.GetAuditOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -1053,7 +1050,7 @@ func (r *Resolver) GetAuditTool(ctx context.Context, req *mcp.CallToolRequest, i
}
func (r *Resolver) AddAuditTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddAuditInput) (*mcp.CallToolResult, types.AddAuditOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateAudit)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateAudit)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -1078,7 +1075,7 @@ func (r *Resolver) AddAuditTool(ctx context.Context, req *mcp.CallToolRequest, i
}
func (r *Resolver) UpdateAuditTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateAuditInput) (*mcp.CallToolResult, types.UpdateAuditOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateAudit)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateAudit)
svc := r.ProboService(ctx, input.ID)
@@ -1103,7 +1100,7 @@ func (r *Resolver) UpdateAuditTool(ctx context.Context, req *mcp.CallToolRequest
}
func (r *Resolver) ListControlsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListControlsInput) (*mcp.CallToolResult, types.ListControlsOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListControls)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListControls)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -1134,7 +1131,7 @@ func (r *Resolver) ListControlsTool(ctx context.Context, req *mcp.CallToolReques
}
func (r *Resolver) GetControlTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetControlInput) (*mcp.CallToolResult, types.GetControlOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -1149,7 +1146,7 @@ func (r *Resolver) GetControlTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) AddControlTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddControlInput) (*mcp.CallToolResult, types.AddControlOutput, error) {
r.MustBeAuthorized(ctx, input.FrameworkID, authz.ActionCreateControl)
r.MustBeAuthorized(ctx, input.FrameworkID, iam.ActionCreateControl)
svc := r.ProboService(ctx, input.FrameworkID)
@@ -1174,7 +1171,7 @@ func (r *Resolver) AddControlTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) UpdateControlTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateControlInput) (*mcp.CallToolResult, types.UpdateControlOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateControl)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateControl)
svc := r.ProboService(ctx, input.ID)
@@ -1199,7 +1196,7 @@ func (r *Resolver) UpdateControlTool(ctx context.Context, req *mcp.CallToolReque
}
func (r *Resolver) LinkControlMeasureTool(ctx context.Context, req *mcp.CallToolRequest, input *types.LinkControlMeasureInput) (*mcp.CallToolResult, types.LinkControlMeasureOutput, error) {
r.MustBeAuthorized(ctx, input.ControlID, authz.ActionCreateControlMeasureMapping)
r.MustBeAuthorized(ctx, input.ControlID, iam.ActionCreateControlMeasureMapping)
svc := r.ProboService(ctx, input.ControlID)
@@ -1212,7 +1209,7 @@ func (r *Resolver) LinkControlMeasureTool(ctx context.Context, req *mcp.CallTool
}
func (r *Resolver) UnlinkControlMeasureTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UnlinkControlMeasureInput) (*mcp.CallToolResult, types.UnlinkControlMeasureOutput, error) {
r.MustBeAuthorized(ctx, input.ControlID, authz.ActionDeleteControlMeasureMapping)
r.MustBeAuthorized(ctx, input.ControlID, iam.ActionDeleteControlMeasureMapping)
svc := r.ProboService(ctx, input.ControlID)
@@ -1225,7 +1222,7 @@ func (r *Resolver) UnlinkControlMeasureTool(ctx context.Context, req *mcp.CallTo
}
func (r *Resolver) LinkControlDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.LinkControlDocumentInput) (*mcp.CallToolResult, types.LinkControlDocumentOutput, error) {
r.MustBeAuthorized(ctx, input.ControlID, authz.ActionCreateControlDocumentMapping)
r.MustBeAuthorized(ctx, input.ControlID, iam.ActionCreateControlDocumentMapping)
svc := r.ProboService(ctx, input.ControlID)
@@ -1238,7 +1235,7 @@ func (r *Resolver) LinkControlDocumentTool(ctx context.Context, req *mcp.CallToo
}
func (r *Resolver) UnlinkControlDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UnlinkControlDocumentInput) (*mcp.CallToolResult, types.UnlinkControlDocumentOutput, error) {
r.MustBeAuthorized(ctx, input.ControlID, authz.ActionDeleteControlDocumentMapping)
r.MustBeAuthorized(ctx, input.ControlID, iam.ActionDeleteControlDocumentMapping)
svc := r.ProboService(ctx, input.ControlID)
@@ -1251,7 +1248,7 @@ func (r *Resolver) UnlinkControlDocumentTool(ctx context.Context, req *mcp.CallT
}
func (r *Resolver) LinkControlAuditTool(ctx context.Context, req *mcp.CallToolRequest, input *types.LinkControlAuditInput) (*mcp.CallToolResult, types.LinkControlAuditOutput, error) {
r.MustBeAuthorized(ctx, input.ControlID, authz.ActionCreateControlAuditMapping)
r.MustBeAuthorized(ctx, input.ControlID, iam.ActionCreateControlAuditMapping)
svc := r.ProboService(ctx, input.ControlID)
@@ -1264,7 +1261,7 @@ func (r *Resolver) LinkControlAuditTool(ctx context.Context, req *mcp.CallToolRe
}
func (r *Resolver) UnlinkControlAuditTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UnlinkControlAuditInput) (*mcp.CallToolResult, types.UnlinkControlAuditOutput, error) {
r.MustBeAuthorized(ctx, input.ControlID, authz.ActionDeleteControlAuditMapping)
r.MustBeAuthorized(ctx, input.ControlID, iam.ActionDeleteControlAuditMapping)
svc := r.ProboService(ctx, input.ControlID)
@@ -1277,7 +1274,7 @@ func (r *Resolver) UnlinkControlAuditTool(ctx context.Context, req *mcp.CallTool
}
func (r *Resolver) LinkControlSnapshotTool(ctx context.Context, req *mcp.CallToolRequest, input *types.LinkControlSnapshotInput) (*mcp.CallToolResult, types.LinkControlSnapshotOutput, error) {
r.MustBeAuthorized(ctx, input.ControlID, authz.ActionCreateControlSnapshotMapping)
r.MustBeAuthorized(ctx, input.ControlID, iam.ActionCreateControlSnapshotMapping)
svc := r.ProboService(ctx, input.ControlID)
@@ -1290,7 +1287,7 @@ func (r *Resolver) LinkControlSnapshotTool(ctx context.Context, req *mcp.CallToo
}
func (r *Resolver) UnlinkControlSnapshotTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UnlinkControlSnapshotInput) (*mcp.CallToolResult, types.UnlinkControlSnapshotOutput, error) {
r.MustBeAuthorized(ctx, input.ControlID, authz.ActionDeleteControlSnapshotMapping)
r.MustBeAuthorized(ctx, input.ControlID, iam.ActionDeleteControlSnapshotMapping)
svc := r.ProboService(ctx, input.ControlID)
@@ -1303,7 +1300,7 @@ func (r *Resolver) UnlinkControlSnapshotTool(ctx context.Context, req *mcp.CallT
}
func (r *Resolver) ListTasksTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListTasksInput) (*mcp.CallToolResult, types.ListTasksOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListTasks)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListTasks)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -1329,7 +1326,7 @@ func (r *Resolver) ListTasksTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) GetTaskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetTaskInput) (*mcp.CallToolResult, types.GetTaskOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -1343,7 +1340,7 @@ func (r *Resolver) GetTaskTool(ctx context.Context, req *mcp.CallToolRequest, in
}
func (r *Resolver) AddTaskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddTaskInput) (*mcp.CallToolResult, types.AddTaskOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateTask)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateTask)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -1368,7 +1365,7 @@ func (r *Resolver) AddTaskTool(ctx context.Context, req *mcp.CallToolRequest, in
}
func (r *Resolver) UpdateTaskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateTaskInput) (*mcp.CallToolResult, types.UpdateTaskOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateTask)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateTask)
svc := r.ProboService(ctx, input.ID)
@@ -1394,7 +1391,7 @@ func (r *Resolver) UpdateTaskTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) AssignTaskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AssignTaskInput) (*mcp.CallToolResult, types.AssignTaskOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionAssignTask)
r.MustBeAuthorized(ctx, input.ID, iam.ActionAssignTask)
svc := r.ProboService(ctx, input.ID)
@@ -1409,7 +1406,7 @@ func (r *Resolver) AssignTaskTool(ctx context.Context, req *mcp.CallToolRequest,
}
func (r *Resolver) UnassignTaskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UnassignTaskInput) (*mcp.CallToolResult, types.UnassignTaskOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUnassignTask)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUnassignTask)
svc := r.ProboService(ctx, input.ID)
@@ -1423,7 +1420,7 @@ func (r *Resolver) UnassignTaskTool(ctx context.Context, req *mcp.CallToolReques
}
func (r *Resolver) ListSnapshotsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListSnapshotsInput) (*mcp.CallToolResult, types.ListSnapshotsOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListSnapshots)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListSnapshots)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -1449,7 +1446,7 @@ func (r *Resolver) ListSnapshotsTool(ctx context.Context, req *mcp.CallToolReque
}
func (r *Resolver) GetSnapshotTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetSnapshotInput) (*mcp.CallToolResult, types.GetSnapshotOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -1463,7 +1460,7 @@ func (r *Resolver) GetSnapshotTool(ctx context.Context, req *mcp.CallToolRequest
}
func (r *Resolver) TakeSnapshotTool(ctx context.Context, req *mcp.CallToolRequest, input *types.TakeSnapshotInput) (*mcp.CallToolResult, types.TakeSnapshotOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateSnapshot)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateSnapshot)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -1485,7 +1482,7 @@ func (r *Resolver) TakeSnapshotTool(ctx context.Context, req *mcp.CallToolReques
}
func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListDocumentsInput) (*mcp.CallToolResult, types.ListDocumentsOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListDocuments)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionListDocuments)
prb := r.ProboService(ctx, input.OrganizationID)
@@ -1521,7 +1518,7 @@ func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolReque
}
func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentInput) (*mcp.CallToolResult, types.GetDocumentOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -1536,7 +1533,7 @@ func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest
}
func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddDocumentInput) (*mcp.CallToolResult, types.AddDocumentOutput, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateDocument)
r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreateDocument)
svc := r.ProboService(ctx, input.OrganizationID)
@@ -1565,7 +1562,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
}
func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDocumentInput) (*mcp.CallToolResult, types.UpdateDocumentOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionUpdateDocument)
r.MustBeAuthorized(ctx, input.ID, iam.ActionUpdateDocument)
svc := r.ProboService(ctx, input.ID)
@@ -1590,7 +1587,7 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
}
func (r *Resolver) ListDocumentVersionsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListDocumentVersionsInput) (*mcp.CallToolResult, types.ListDocumentVersionsOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentID, authz.ActionDocumentVersion)
r.MustBeAuthorized(ctx, input.DocumentID, iam.ActionDocumentVersion)
pageOrderBy := page.OrderBy[coredata.DocumentVersionOrderField]{
Field: coredata.DocumentVersionOrderFieldCreatedAt,
@@ -1615,7 +1612,7 @@ func (r *Resolver) ListDocumentVersionsTool(ctx context.Context, req *mcp.CallTo
}
func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentVersionInput) (*mcp.CallToolResult, types.GetDocumentVersionOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
svc := r.ProboService(ctx, input.ID)
@@ -1630,7 +1627,7 @@ func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallTool
}
func (r *Resolver) CreateDraftDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateDraftDocumentVersionInput) (*mcp.CallToolResult, types.CreateDraftDocumentVersionOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentID, authz.ActionCreateDraftDocumentVersion)
r.MustBeAuthorized(ctx, input.DocumentID, iam.ActionCreateDraftDocumentVersion)
svc := r.ProboService(ctx, input.DocumentID)
@@ -1645,7 +1642,7 @@ func (r *Resolver) CreateDraftDocumentVersionTool(ctx context.Context, req *mcp.
}
func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDocumentVersionInput) (*mcp.CallToolResult, types.UpdateDocumentVersionOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentVersionID, authz.ActionUpdateDocumentVersion)
r.MustBeAuthorized(ctx, input.DocumentVersionID, iam.ActionUpdateDocumentVersion)
svc := r.ProboService(ctx, input.DocumentVersionID)
@@ -1666,11 +1663,11 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT
}
func (r *Resolver) PublishDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishDocumentVersionInput) (*mcp.CallToolResult, types.PublishDocumentVersionOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentID, authz.ActionPublishDocumentVersion)
r.MustBeAuthorized(ctx, input.DocumentID, iam.ActionPublishDocumentVersion)
svc := r.ProboService(ctx, input.DocumentID)
user := serverauth.UserFromContext(ctx)
user := connect_v1.UserFromContext(ctx)
document, documentVersion, err := svc.Documents.PublishVersion(ctx, input.DocumentID, user.ID, input.Changelog)
if err != nil {
@@ -1684,7 +1681,7 @@ func (r *Resolver) PublishDocumentVersionTool(ctx context.Context, req *mcp.Call
}
func (r *Resolver) ListDocumentVersionSignaturesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListDocumentVersionSignaturesInput) (*mcp.CallToolResult, types.ListDocumentVersionSignaturesOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentVersionID, authz.ActionDocumentVersion)
r.MustBeAuthorized(ctx, input.DocumentVersionID, iam.ActionDocumentVersion)
prb := r.ProboService(ctx, input.DocumentVersionID)
@@ -1717,7 +1714,7 @@ func (r *Resolver) ListDocumentVersionSignaturesTool(ctx context.Context, req *m
}
func (r *Resolver) GetDocumentVersionSignatureTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentVersionSignatureInput) (*mcp.CallToolResult, types.GetDocumentVersionSignatureOutput, error) {
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
r.MustBeAuthorized(ctx, input.ID, iam.ActionGet)
prb := r.ProboService(ctx, input.ID)
@@ -1732,7 +1729,7 @@ func (r *Resolver) GetDocumentVersionSignatureTool(ctx context.Context, req *mcp
}
func (r *Resolver) RequestDocumentVersionSignatureTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RequestDocumentVersionSignatureInput) (*mcp.CallToolResult, types.RequestDocumentVersionSignatureOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentVersionID, authz.ActionRequestSignature)
r.MustBeAuthorized(ctx, input.DocumentVersionID, iam.ActionRequestSignature)
svc := r.ProboService(ctx, input.DocumentVersionID)
@@ -1753,7 +1750,7 @@ func (r *Resolver) RequestDocumentVersionSignatureTool(ctx context.Context, req
}
func (r *Resolver) DeleteDraftDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteDraftDocumentVersionInput) (*mcp.CallToolResult, types.DeleteDraftDocumentVersionOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentVersionID, authz.ActionDeleteDraftDocumentVersion)
r.MustBeAuthorized(ctx, input.DocumentVersionID, iam.ActionDeleteDraftDocumentVersion)
svc := r.ProboService(ctx, input.DocumentVersionID)
@@ -1768,7 +1765,7 @@ func (r *Resolver) DeleteDraftDocumentVersionTool(ctx context.Context, req *mcp.
}
func (r *Resolver) DeleteDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteDocumentInput) (*mcp.CallToolResult, types.DeleteDocumentOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentID, authz.ActionDeleteDocument)
r.MustBeAuthorized(ctx, input.DocumentID, iam.ActionDeleteDocument)
svc := r.ProboService(ctx, input.DocumentID)
@@ -1783,7 +1780,7 @@ func (r *Resolver) DeleteDocumentTool(ctx context.Context, req *mcp.CallToolRequ
}
func (r *Resolver) CancelSignatureRequestTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CancelSignatureRequestInput) (*mcp.CallToolResult, types.CancelSignatureRequestOutput, error) {
r.MustBeAuthorized(ctx, input.DocumentVersionSignatureID, authz.ActionCancelSignatureRequest)
r.MustBeAuthorized(ctx, input.DocumentVersionSignatureID, iam.ActionCancelSignatureRequest)
svc := r.ProboService(ctx, input.DocumentVersionSignatureID)

View File

@@ -8,33 +8,27 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.gearno.de/kit/log"
mcpgenmcp "go.probo.inc/mcpgen/mcp"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
"go.probo.inc/probo/pkg/server/api/mcp/mcputils"
"go.probo.inc/probo/pkg/server/api/mcp/v1/server"
serverauth "go.probo.inc/probo/pkg/server/auth"
)
func (r *Resolver) ProboService(ctx context.Context, objectID gid.GID) *probo.TenantService {
serverauth.RequireTenantAccess(ctx, objectID.TenantID())
return r.proboSvc.WithTenant(objectID.TenantID())
}
func NewMux(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, cfg Config) *chi.Mux {
func NewMux(logger *log.Logger, proboSvc *probo.Service, iamSvc *iam.Service) *chi.Mux {
logger = logger.Named("mcp.v1")
logger.Info("initializing MCP server",
log.String("version", cfg.Version),
log.String("request_timeout", cfg.RequestTimeout.String()),
)
logger.Info("initializing MCP server")
// server.AddReceivingMiddleware(mcputils.LoggingMiddleware(logger))
resolver := &Resolver{
proboSvc: proboSvc,
authSvc: authSvc,
authzSvc: authzSvc,
iamSvc: iamSvc,
logger: logger,
}
@@ -57,10 +51,9 @@ func NewMux(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service,
},
)
authHandler := WithMCPAuth(logger, authSvc, authzSvc, handler)
r := chi.NewMux()
r.Handle("/", authHandler)
r.Use(connect_v1.NewAPIKeyMiddleware(iamSvc))
r.Handle("/", RequireAPIKeyHandler(logger, handler))
logger.Info("MCP server initialized successfully")

View File

@@ -18,29 +18,16 @@ package trust_v1
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
console_v1 "go.probo.inc/probo/pkg/server/api/console/v1"
slack_v1 "go.probo.inc/probo/pkg/server/api/slack/v1"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
"go.probo.inc/probo/pkg/server/api/trust/v1/schema"
"go.probo.inc/probo/pkg/server/api/trust/v1/trustauth"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/server/session"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/trust"
)
@@ -58,242 +45,42 @@ type (
}
Resolver struct {
trustCenterSvc *trust.Service
authCfg console_v1.AuthConfig
trustAuthCfg TrustAuthConfig
trust *trust.Service
}
ctxKey struct{ name string }
)
var (
sessionContextKey = &ctxKey{name: "session"}
userContextKey = &ctxKey{name: "user"}
userTenantContextKey = &ctxKey{name: "user_tenants"}
tokenAccessContextKey = &ctxKey{name: "token_access"}
)
func SessionFromContext(ctx context.Context) *coredata.Session {
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
return session
}
func UserFromContext(ctx context.Context) *coredata.User {
user, _ := ctx.Value(userContextKey).(*coredata.User)
return user
}
func TokenAccessFromContext(ctx context.Context) *trustauth.TokenAccessData {
tokenAccess, _ := ctx.Value(tokenAccessContextKey).(*trustauth.TokenAccessData)
return tokenAccess
}
// UserFromContext implements trustauth.ContextAccessor interface
func (r *Resolver) UserFromContext(ctx context.Context) *coredata.User {
return UserFromContext(ctx)
}
// TokenAccessFromContext implements trustauth.ContextAccessor interface
func (r *Resolver) TokenAccessFromContext(ctx context.Context) *trustauth.TokenAccessData {
return TokenAccessFromContext(ctx)
}
func NewMux(
logger *log.Logger,
authSvc *auth.Service,
authzSvc *authz.Service,
iamSvc *iam.Service,
trustSvc *trust.Service,
authCfg console_v1.AuthConfig,
trustAuthCfg TrustAuthConfig,
// TODO: Remove this after successful migration to /slack/v1.
slackSvc *slack.Service,
cookieConfig securecookie.Config,
) *chi.Mux {
r := chi.NewMux()
r.Handle("/graphql", graphqlHandler(logger, authSvc, authzSvc, trustSvc, authCfg, trustAuthCfg))
sessionMiddleware := connect_v1.NewSessionMiddleware(iamSvc, cookieConfig)
r.Use(sessionMiddleware)
r.Post("/auth/authenticate", authTokenHandler(trustSvc, trustAuthCfg))
r.Delete("/auth/logout", trustCenterLogoutHandler(authCfg, trustAuthCfg))
config := schema.Config{Resolvers: &Resolver{trust: trustSvc}}
es := schema.NewExecutableSchema(config)
graphqlHandler := gqlutils.NewHandler(es, logger)
// Backward compatibility: support old /trust/v1/slack endpoint
// TODO: Remove this after successful migration to /slack/v1 and then make SlackHandler PRIVATE in slack_v1 package.
r.Post("/slack", slack_v1.SlackHandler(slackSvc, slackSvc.GetSlackSigningSecret(), logger, trustSvc))
r.Handle("/graphql", graphqlHandler)
return r
}
func graphqlHandler(logger *log.Logger, authSvc *auth.Service, authzSvc *authz.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig) http.HandlerFunc {
resolver := &Resolver{
trustCenterSvc: trustSvc,
authCfg: authCfg,
trustAuthCfg: trustAuthCfg,
}
c := schema.Config{
Resolvers: resolver,
}
c.Directives.MustBeAuthenticated = trustauth.MustBeAuthenticatedDirective(resolver)
es := schema.NewExecutableSchema(c)
srv := handler.New(es)
srv.AddTransport(transport.POST{})
srv.AddTransport(transport.GET{})
srv.AddTransport(transport.Options{})
srv.Use(extension.Introspection{})
srv.Use(gqlutils.NewTracingExtension(logger))
srv.SetRecoverFunc(gqlutils.RecoverFunc)
return WithSession(authSvc, authzSvc, trustSvc, authCfg, trustAuthCfg, srv.ServeHTTP)
}
func (r *Resolver) RootTrustService(ctx context.Context) *trust.TenantService {
return r.trustCenterSvc.WithTenant(gid.NewTenantID())
return r.trust.WithTenant(gid.NewTenantID())
}
func (r *Resolver) PublicTrustService(ctx context.Context, tenantID gid.TenantID) *trust.TenantService {
return r.trustCenterSvc.WithTenant(tenantID)
return r.trust.WithTenant(tenantID)
}
func (r *Resolver) PrivateTrustService(ctx context.Context, tenantID gid.TenantID) (*trust.TenantService, error) {
if err := trustauth.ValidateTenantAccess(ctx, r, userTenantContextKey, tenantID); err != nil {
return nil, fmt.Errorf("cannot access trust center: %w", err)
}
// if err := trustauth.ValidateTenantAccess(ctx, r, userTenantContextKey, tenantID); err != nil {
// return nil, fmt.Errorf("cannot access trust center: %w", err)
// }
return r.trustCenterSvc.WithTenant(tenantID), nil
}
func WithSession(authSvc *auth.Service, authzSvc *authz.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ip := extractIPAddress(r)
ctx = context.WithValue(ctx, coredata.ContextKeyIPAddress, ip)
if authCtx := tryTokenAuth(ctx, w, r, trustSvc, trustAuthCfg); authCtx != nil {
next(w, r.WithContext(authCtx))
return
}
if authCtx := trySessionAuth(ctx, w, r, authSvc, authzSvc, authCfg); authCtx != nil {
next(w, r.WithContext(authCtx))
updateSessionIfNeeded(authCtx, authSvc)
return
}
next(w, r.WithContext(ctx))
}
}
func trySessionAuth(ctx context.Context, w http.ResponseWriter, r *http.Request, authSvc *auth.Service, authzSvc *authz.Service, authCfg console_v1.AuthConfig) context.Context {
sessionAuthCfg := session.AuthConfig{
CookieName: authCfg.CookieName,
CookieSecret: authCfg.CookieSecret,
CookieSecure: authCfg.CookieSecure,
}
errorHandler := session.ErrorHandler{
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
},
OnTenantError: func(err error) {
session.ClearCookie(w, sessionAuthCfg)
},
}
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
return nil
}
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
ctx = context.WithValue(ctx, userContextKey, authResult.User)
ctx = context.WithValue(ctx, userTenantContextKey, &authResult.TenantIDs)
return ctx
}
func tryTokenAuth(ctx context.Context, w http.ResponseWriter, r *http.Request, trustSvc *trust.Service, trustAuthCfg TrustAuthConfig) context.Context {
cookie, err := r.Cookie(trustAuthCfg.CookieName)
if err != nil {
return nil
}
basicPayload, err := statelesstoken.ValidateToken[probo.TrustCenterAccessData](
trustAuthCfg.TokenSecret,
trustAuthCfg.TokenType,
cookie.Value,
)
if err != nil {
clearTokenCookie(w, trustAuthCfg)
return nil
}
tenantID := basicPayload.Data.TrustCenterID.TenantID()
tenantSvc := trustSvc.WithTenant(tenantID)
if err := tenantSvc.TrustCenterAccesses.ValidateToken(ctx, basicPayload.Data.TrustCenterID, basicPayload.Data.Email); err != nil {
clearTokenCookie(w, trustAuthCfg)
return nil
}
tokenAccess := &trustauth.TokenAccessData{
TrustCenterID: basicPayload.Data.TrustCenterID,
Email: basicPayload.Data.Email,
TenantID: tenantID,
Scope: trustAuthCfg.Scope,
}
return context.WithValue(ctx, tokenAccessContextKey, tokenAccess)
}
func clearTokenCookie(w http.ResponseWriter, trustAuthCfg TrustAuthConfig) {
http.SetCookie(w, &http.Cookie{
Name: trustAuthCfg.CookieName,
Value: "",
Domain: trustAuthCfg.CookieDomain,
Path: "/",
MaxAge: -1,
Secure: trustAuthCfg.CookieSecure,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
func updateSessionIfNeeded(ctx context.Context, authSvc *auth.Service) {
session := SessionFromContext(ctx)
if session != nil {
if _, err := authSvc.UpdateSession(ctx, session.ID); err != nil {
panic(fmt.Errorf("cannot update session: %w", err))
}
}
}
func extractIPAddress(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if ip := strings.Split(xff, ",")[0]; ip != "" {
return strings.TrimSpace(ip)
}
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
if ip := strings.Split(r.RemoteAddr, ":")[0]; ip != "" {
return ip
}
return "unknown"
return r.trust.WithTenant(tenantID), nil
}

View File

@@ -24,22 +24,18 @@ import (
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
console_v1 "go.probo.inc/probo/pkg/server/api/console/v1"
"go.probo.inc/probo/pkg/server/session"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/trust"
)
type ctxKey struct {
name string
}
var (
CustomDomainTenantIDKey = &ctxKey{name: "custom_domain_tenant_id"}
CustomDomainOrganizationIDKey = &ctxKey{name: "custom_domain_organization_id"}
)
func GetCustomDomainTenantID(ctx context.Context) (gid.TenantID, bool) {
tenantID, ok := ctx.Value(CustomDomainTenantIDKey).(gid.TenantID)
return tenantID, ok
}
func GetCustomDomainOrganizationID(ctx context.Context) (gid.GID, bool) {
organizationID, ok := ctx.Value(CustomDomainOrganizationIDKey).(gid.GID)
return organizationID, ok
@@ -141,37 +137,3 @@ func validateTrustCenterAccessToken(ctx context.Context, trustSvc *trust.Service
return &token.Data, nil
}
func trustCenterLogoutHandler(authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Determine cookie domain: use custom domain if present, otherwise use configured domain
cookieDomain := trustAuthCfg.CookieDomain
if _, ok := GetCustomDomainOrganizationID(r.Context()); ok {
// On custom domain, use the request host
if r.TLS != nil && r.TLS.ServerName != "" {
cookieDomain = r.TLS.ServerName
}
}
http.SetCookie(w, &http.Cookie{
Name: trustAuthCfg.CookieName,
Value: "",
Domain: cookieDomain,
Path: "/",
MaxAge: -1,
Secure: trustAuthCfg.CookieSecure,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
session.ClearCookie(w, session.AuthConfig{
CookieName: authCfg.CookieName,
CookieSecret: authCfg.CookieSecret,
CookieSecure: authCfg.CookieSecure,
})
httpserver.RenderJSON(w, http.StatusOK, map[string]string{
"message": "Logged out successfully",
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,94 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/server/session"
)
type (
AcceptInvitationRequest struct {
InvitationID gid.GID `json:"invitationId"`
}
AcceptInvitationResponse struct {
InvitationID gid.GID `json:"invitationId"`
}
)
func AcceptInvitationHandler(authSvc *authsvc.Service, authzSvc *authz.Service, cookieName string, cookieSecret string, cookieSecure bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionAuthCfg := session.AuthConfig{
CookieName: cookieName,
CookieSecret: cookieSecret,
CookieSecure: cookieSecure,
}
errorHandler := session.ErrorHandler{
OnCookieError: func(err error) {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
},
OnTenantError: func(err error) {
panic(fmt.Errorf("cannot list tenants for user: %w", err))
},
}
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
return
}
// Parse request body
var req AcceptInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid request body"))
return
}
// Accept the invitation
_, err := authzSvc.AcceptInvitationByID(ctx, req.InvitationID, authResult.User.ID)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, err)
return
}
response := AcceptInvitationResponse(req)
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -1,120 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"context"
"net/http"
"slices"
"strings"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
var (
// UserContextKey is the context key for the authenticated user
UserContextKey = &ctxKey{name: "user"}
// UserTenantContextKey is the context key for tenant access information
UserTenantContextKey = &ctxKey{name: "user_tenants"}
// UserAPIKeyContextKey is the context key for the API key used for authentication
UserAPIKeyContextKey = &ctxKey{name: "user_api_key"}
)
type UserTenantAccess struct {
TenantIDs []gid.TenantID
AuthErrors map[gid.TenantID]error
}
// UserFromContext extracts the authenticated user from the context.
func UserFromContext(ctx context.Context) *coredata.User {
user, _ := ctx.Value(UserContextKey).(*coredata.User)
return user
}
// UserAPIKeyFromContext extracts the API key from the context.
func UserAPIKeyFromContext(ctx context.Context) *coredata.UserAPIKey {
userAPIKey, _ := ctx.Value(UserAPIKeyContextKey).(*coredata.UserAPIKey)
return userAPIKey
}
// UserTenantAccessFromContext extracts the tenant access information from the context.
func UserTenantAccessFromContext(ctx context.Context) *UserTenantAccess {
access, _ := ctx.Value(UserTenantContextKey).(*UserTenantAccess)
return access
}
// AuthenticateWithAPIKey attempts to authenticate using an API key from the Authorization header.
// It returns a context with authentication information if successful, or nil if no API key
// was provided or authentication failed. This function does not return errors - it silently
// fails to allow fallback to other authentication methods.
func AuthenticateWithAPIKey(ctx context.Context, r *http.Request, authSvc *auth.Service, authzSvc *authz.Service) context.Context {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil
}
if !strings.HasPrefix(authHeader, "Bearer ") {
return nil
}
apiKeyString := strings.TrimPrefix(authHeader, "Bearer ")
user, userAPIKey, err := authSvc.ValidateUserAPIKey(ctx, apiKeyString)
if err != nil {
return nil
}
organizations, err := authzSvc.GetAllOrganizationsForUserAPIKeyId(ctx, userAPIKey.ID)
if err != nil {
return nil
}
tenantIDs := make([]gid.TenantID, 0, len(organizations))
for _, org := range organizations {
tenantIDs = append(tenantIDs, org.ID.TenantID())
}
ctx = context.WithValue(ctx, UserContextKey, user)
ctx = context.WithValue(ctx, UserAPIKeyContextKey, userAPIKey)
ctx = context.WithValue(ctx, UserTenantContextKey, &UserTenantAccess{
TenantIDs: tenantIDs,
AuthErrors: make(map[gid.TenantID]error),
})
return ctx
}
// RequireTenantAccess ensures that the authenticated user has access to the specified tenant.
// It panics with an authz.TenantAccessError if access is denied.
func RequireTenantAccess(ctx context.Context, tenantID gid.TenantID) {
access := UserTenantAccessFromContext(ctx)
if access == nil {
panic(&authz.TenantAccessError{Message: "tenant not found"})
}
if !slices.Contains(access.TenantIDs, tenantID) {
if access.AuthErrors != nil {
if authErr := access.AuthErrors[tenantID]; authErr != nil {
panic(authErr)
}
}
panic(&authz.TenantAccessError{Message: "tenant not found"})
}
}

View File

@@ -1,77 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/filemanager"
)
type Config struct {
Auth *authsvc.Service
Authz *authz.Service
SAML *authsvc.SAMLService
CookieName string
CookieDomain string
SessionDuration time.Duration
CookieSecret string
CookieSecure bool
FileManager *filemanager.Service
Logger *log.Logger
}
type Server struct {
router *chi.Mux
}
func NewServer(cfg Config) (*Server, error) {
router := chi.NewRouter()
router.Post("/register", SignUpHandler(cfg.Auth, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure))
router.Post("/login", SignInHandler(cfg.Auth, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure))
router.Delete("/logout", SignOutHandler(cfg.Auth, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure))
router.Post("/signup-from-invitation", SignupFromInvitationHandler(cfg.Auth, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure))
router.Post("/forget-password", ForgetPasswordHandler(cfg.Auth))
router.Post("/reset-password", ResetPasswordHandler(cfg.Auth))
router.Post("/check-sso", SAMLCheckSSOHandler(cfg.Auth, cfg.Logger))
router.Get("/organizations", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, ListOrganizationsHandler(cfg.Auth, cfg.Authz)))
router.Get("/organizations/{organizationID}/logo", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, OrganizationLogoHandler(cfg.Auth, cfg.FileManager)))
router.Get("/invitations", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, ListInvitationsHandler(cfg.Authz)))
router.Post("/invitations/accept", AcceptInvitationHandler(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure))
router.Get("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, ListUserAPIKeysHandler(cfg.Auth, cfg.Authz)))
router.Post("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, CreateUserAPIKeyHandler(cfg.Auth, cfg.Authz)))
router.Get("/api-keys/{id}", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, GetUserAPIKeyHandler(cfg.Auth)))
router.Put("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, UpdateUserAPIKeyHandler(cfg.Auth, cfg.Authz)))
router.Delete("/api-keys", RequireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, DeleteUserAPIKeyHandler(cfg.Auth)))
router.Get("/saml/login/{samlConfigID}", SAMLLoginHandler(cfg.SAML, cfg.Auth, cfg.Logger))
router.Post("/saml/consume", SAMLACSHandler(cfg.SAML, cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure, cfg.SessionDuration, cfg.Logger))
router.Get("/saml/metadata", SAMLMetadataHandler(cfg.SAML))
return &Server{
router: router,
}, nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}

View File

@@ -1,89 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"context"
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/server/session"
)
type ctxKey struct{ name string }
var (
sessionContextKey = &ctxKey{name: "session"}
)
func RequireAuth(
authSvc *authsvc.Service,
authzSvc *authz.Service,
cookieName string,
cookieSecret string,
cookieSecure bool,
next http.HandlerFunc,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionAuthCfg := session.AuthConfig{
CookieName: cookieName,
CookieSecret: cookieSecret,
CookieSecure: cookieSecure,
}
errorHandler := session.ErrorHandler{
OnCookieError: func(err error) {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
},
OnTenantError: func(err error) {
panic(fmt.Errorf("cannot list tenants for user: %w", err))
},
}
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
return
}
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
ctx = context.WithValue(ctx, UserContextKey, authResult.User)
next(w, r.WithContext(ctx))
}
}
func SessionFromContext(ctx context.Context) *coredata.Session {
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
return session
}

View File

@@ -1,145 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"fmt"
"net/http"
"time"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type (
CreateUserAPIKeyRequest struct {
Name string `json:"name"`
ExpiresAt time.Time `json:"expiresAt"`
Organizations []UserAPIKeyOrganizationMembershipRequest `json:"organizations"`
}
UserAPIKeyOrganizationMembershipRequest struct {
OrganizationID string `json:"organizationId"`
Role string `json:"role"`
}
CreateUserAPIKeyResponse struct {
UserAPIKey UserAPIKeyResponse `json:"apiKey"`
Key string `json:"key"`
}
)
func CreateUserAPIKeyHandler(authSvc *authsvc.Service, authzSvc *authz.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
var req CreateUserAPIKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body",
})
return
}
if req.ExpiresAt.IsZero() {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "expiresAt is required",
})
return
}
if req.ExpiresAt.Before(time.Now()) {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "expiration date must be in the future",
})
return
}
if len(req.Organizations) == 0 {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "at least one organization is required",
})
return
}
name := req.Name
if name == "" {
name = time.Now().Format("2006-01-02")
}
orgInputs := make([]authsvc.UserAPIKeyOrganizationRequest, len(req.Organizations))
for i, org := range req.Organizations {
orgID, err := gid.ParseGID(org.OrganizationID)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid organization id",
})
return
}
// Check if user is an OWNER for this organization
tenantAuthzSvc := authzSvc.WithTenant(orgID.TenantID())
role, err := tenantAuthzSvc.GetUserRoleInOrganization(ctx, user.ID, orgID)
if err != nil {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "user does not have access to this organization",
})
return
}
if role != coredata.MembershipRoleOwner {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "only owners can create API keys for this organization",
})
return
}
orgInputs[i] = authsvc.UserAPIKeyOrganizationRequest{
OrganizationID: orgID,
Role: coredata.APIRole(org.Role),
}
}
memberships, err := authSvc.ValidateAndBuildUserAPIKeyMemberships(ctx, user.ID, orgInputs)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid organizations",
})
return
}
userAPIKey, key, err := authSvc.CreateUserAPIKey(ctx, user.ID, name, req.ExpiresAt, memberships)
if err != nil {
panic(fmt.Errorf("cannot create user api key: %w", err))
}
response := CreateUserAPIKeyResponse{
UserAPIKey: UserAPIKeyResponse{
ID: userAPIKey.ID,
Name: userAPIKey.Name,
ExpiresAt: userAPIKey.ExpiresAt,
CreatedAt: userAPIKey.CreatedAt,
},
Key: key,
}
httpserver.RenderJSON(w, http.StatusCreated, response)
}
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"errors"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type DeleteUserAPIKeyRequest struct {
ID string `json:"id"`
}
type DeleteUserAPIKeyResponse struct {
ID string `json:"id"`
}
func DeleteUserAPIKeyHandler(authSvc *authsvc.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
var req DeleteUserAPIKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body",
})
return
}
if req.ID == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "user api key id is required",
})
return
}
userAPIKeyID, err := gid.ParseGID(req.ID)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid user api key id",
})
return
}
if err := authSvc.DeleteUserAPIKey(ctx, userAPIKeyID, user.ID); err != nil {
var errNotFound *coredata.ErrUserAPIKeyNotFound
if errors.As(err, &errNotFound) {
httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{
"error": "user api key not found",
})
return
}
httpserver.RenderJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to delete user api key",
})
return
}
response := DeleteUserAPIKeyResponse(req)
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -1,56 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/mail"
)
type (
ForgetPasswordRequest struct {
Email mail.Addr `json:"email"`
}
ForgetPasswordResponse struct {
Success bool `json:"success"`
}
)
func ForgetPasswordHandler(authSvc *authsvc.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req ForgetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
err := authSvc.ForgetPassword(r.Context(), req.Email)
if err != nil {
// For security reasons, we don't expose whether an email exists or not
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot process request: %w", err))
return
}
httpserver.RenderJSON(w, http.StatusOK, ForgetPasswordResponse{
Success: true,
})
}
}

View File

@@ -1,65 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"net/http"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/gid"
)
type GetUserAPIKeyResponse struct {
Key string `json:"key"`
}
func GetUserAPIKeyHandler(authSvc *authsvc.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
userAPIKeyIDStr := chi.URLParam(r, "id")
if userAPIKeyIDStr == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "user api key id is required",
})
return
}
userAPIKeyID, err := gid.ParseGID(userAPIKeyIDStr)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid user api key id",
})
return
}
key, err := authSvc.GetUserAPIKey(ctx, userAPIKeyID, user.ID)
if err != nil {
httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{
"error": "user api key not found",
})
return
}
response := GetUserAPIKeyResponse{
Key: key,
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -1,93 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"fmt"
"net/http"
"time"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/gid"
)
type (
ListUserAPIKeysResponse struct {
UserAPIKeys []UserAPIKeyResponse `json:"apiKeys"`
}
UserAPIKeyResponse struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
Organizations []UserAPIKeyOrganizationMembership `json:"organizations"`
}
UserAPIKeyOrganizationMembership struct {
OrganizationID gid.GID `json:"organizationId"`
OrganizationName string `json:"organizationName"`
Role string `json:"role"`
}
)
func ListUserAPIKeysHandler(authSvc *authsvc.Service, authzSvc *authz.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
organizations, err := authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil {
panic(fmt.Errorf("cannot list organizations for user: %w", err))
}
tenantIDs := make([]gid.TenantID, 0, len(organizations))
for _, org := range organizations {
tenantIDs = append(tenantIDs, org.ID.TenantID())
}
userAPIKeysWithMemberships, err := authSvc.ListUserAPIKeysWithMemberships(ctx, user.ID, tenantIDs)
if err != nil {
panic(fmt.Errorf("cannot list user api keys: %w", err))
}
response := ListUserAPIKeysResponse{
UserAPIKeys: make([]UserAPIKeyResponse, 0, len(userAPIKeysWithMemberships)),
}
for _, keyWithMemberships := range userAPIKeysWithMemberships {
organizations := make([]UserAPIKeyOrganizationMembership, 0, len(keyWithMemberships.Memberships))
for _, membership := range keyWithMemberships.Memberships {
organizations = append(organizations, UserAPIKeyOrganizationMembership{
OrganizationID: membership.OrganizationID,
OrganizationName: membership.OrganizationName,
Role: membership.Role.String(),
})
}
response.UserAPIKeys = append(response.UserAPIKeys, UserAPIKeyResponse{
ID: keyWithMemberships.UserAPIKey.ID,
Name: keyWithMemberships.UserAPIKey.Name,
ExpiresAt: keyWithMemberships.UserAPIKey.ExpiresAt,
CreatedAt: keyWithMemberships.UserAPIKey.CreatedAt,
Organizations: organizations,
})
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -1,87 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
type (
ListInvitationsResponse struct {
Invitations []InvitationResponse `json:"invitations"`
}
InvitationResponse struct {
ID gid.GID `json:"id"`
Email mail.Addr `json:"email"`
FullName string `json:"fullName"`
Role string `json:"role"`
ExpiresAt string `json:"expiresAt"`
AcceptedAt *string `json:"acceptedAt,omitempty"`
CreatedAt string `json:"createdAt"`
Organization OrganizationResponseSummary `json:"organization"`
}
OrganizationResponseSummary struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
}
)
func ListInvitationsHandler(authzSvc *authz.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
invitations, err := authzSvc.GetUserPendingInvitations(ctx, user.EmailAddress)
if err != nil {
panic(fmt.Errorf("cannot list invitations for user: %w", err))
}
response := ListInvitationsResponse{
Invitations: make([]InvitationResponse, 0, len(invitations)),
}
for _, invitation := range invitations {
invitationResp := InvitationResponse{
ID: invitation.ID,
Email: invitation.Email,
FullName: invitation.FullName,
Role: invitation.Role.String(),
ExpiresAt: invitation.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"),
CreatedAt: invitation.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
Organization: OrganizationResponseSummary{
ID: invitation.Organization.ID,
Name: invitation.Organization.Name,
},
}
if invitation.AcceptedAt != nil {
acceptedAtStr := invitation.AcceptedAt.Format("2006-01-02T15:04:05Z07:00")
invitationResp.AcceptedAt = &acceptedAtStr
}
response.Invitations = append(response.Invitations, invitationResp)
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type (
AuthenticationStatus string
ListOrganizationsResponse struct {
Organizations []OrganizationResponse `json:"organizations"`
}
OrganizationResponse struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
LogoURL *string `json:"logoUrl,omitempty"`
AuthenticationMethod string `json:"authenticationMethod"` // "password", "saml", or "any"
AuthStatus AuthenticationStatus `json:"authStatus"` // "authenticated", "unauthenticated", "expired"
LoginURL string `json:"loginUrl"` // URL to login (SAML or password login page)
}
)
const (
AuthStatusAuthenticated AuthenticationStatus = "authenticated"
AuthStatusUnauthenticated AuthenticationStatus = "unauthenticated"
AuthStatusExpired AuthenticationStatus = "expired"
)
func buildOrganizationResponse(
org *coredata.Organization,
accessResult authsvc.AccessResult,
sessionData coredata.SessionData,
) OrganizationResponse {
// Generate logo URL path if organization has a logo
var logoURL *string
if org.LogoFileID != nil {
url := fmt.Sprintf("/connect/organizations/%s/logo", org.ID)
logoURL = &url
}
orgResponse := OrganizationResponse{
ID: org.ID,
Name: org.Name,
LogoURL: logoURL,
}
// User does not have required authentication
if !accessResult.Allowed {
orgResponse.AuthStatus = AuthStatusUnauthenticated
switch accessResult.MissingAuth {
case authsvc.AuthMethodSAML, authsvc.AuthMethodAny:
orgResponse.AuthenticationMethod = "saml"
if accessResult.SAMLConfig != nil {
orgResponse.LoginURL = fmt.Sprintf("/connect/saml/login/%s", accessResult.SAMLConfig.ID)
}
case authsvc.AuthMethodPassword:
orgResponse.AuthenticationMethod = "password"
orgResponse.LoginURL = "/auth/login?method=password"
}
return orgResponse
}
// User has required authentication
orgResponse.AuthStatus = AuthStatusAuthenticated
if sessionData.PasswordAuthenticated {
orgResponse.AuthenticationMethod = "password"
orgResponse.LoginURL = "/auth/login?method=password"
} else if samlInfo, ok := sessionData.SAMLAuthenticatedOrgs[org.ID.String()]; ok {
orgResponse.AuthenticationMethod = "saml"
orgResponse.LoginURL = fmt.Sprintf("/connect/saml/login/%s", samlInfo.SAMLConfigID)
} else {
orgResponse.AuthenticationMethod = "any"
orgResponse.LoginURL = "/auth/login?method=password"
}
return orgResponse
}
func ListOrganizationsHandler(authSvc *authsvc.Service, authzSvc *authz.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
sess := SessionFromContext(ctx)
var organizations coredata.Organizations
var err error
roleFilter := r.URL.Query().Get("role")
if roleFilter != "" {
role := coredata.MembershipRole(roleFilter)
organizations, err = authzSvc.GetUserOrganizationsWithRole(ctx, user.ID, role)
if err != nil {
panic(fmt.Errorf("cannot list organizations for user with role: %w", err))
}
} else {
organizations, err = authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil {
panic(fmt.Errorf("cannot list organizations for user: %w", err))
}
}
orgIDs := make([]gid.GID, len(organizations))
for i, org := range organizations {
orgIDs[i] = org.ID
}
accessResults, err := authSvc.CheckOrganizationAccess(ctx, user, orgIDs, sess)
if err != nil {
panic(fmt.Errorf("cannot check organization access: %w", err))
}
response := ListOrganizationsResponse{
Organizations: make([]OrganizationResponse, 0, len(organizations)),
}
for _, org := range organizations {
accessResult := accessResults[org.ID]
orgResponse := buildOrganizationResponse(org, accessResult, sess.Data)
response.Organizations = append(response.Organizations, orgResponse)
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"context"
"fmt"
"net/http"
"time"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"github.com/go-chi/chi/v5"
)
func OrganizationLogoHandler(authSvc *authsvc.Service, fileManager interface {
GenerateFileUrl(ctx context.Context, file *coredata.File, duration time.Duration) (string, error)
}) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
session := SessionFromContext(ctx)
organizationIDStr := chi.URLParam(r, "organizationID")
organizationID, err := gid.ParseGID(organizationIDStr)
if err != nil {
http.Error(w, "Invalid organization ID", http.StatusBadRequest)
return
}
logoFile, err := authSvc.GetOrganizationLogoFile(ctx, user, organizationID, session)
if err != nil {
panic(fmt.Errorf("cannot get organization logo: %w", err))
}
presignedURL, err := fileManager.GenerateFileUrl(ctx, logoFile, 1*time.Hour)
if err != nil {
panic(fmt.Errorf("cannot generate presigned URL: %w", err))
}
w.Header().Set("Cache-Control", "public, max-age=3600")
http.Redirect(w, r, presignedURL, http.StatusFound)
}
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"fmt"
"net/http"
"errors"
authsvc "go.probo.inc/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
type (
ResetPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
ResetPasswordResponse struct {
Success bool `json:"success"`
}
)
func ResetPasswordHandler(authSvc *authsvc.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req ResetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
err := authSvc.ResetPassword(r.Context(), req.Token, req.Password)
if err != nil {
var invalidPasswordErr *authsvc.ErrInvalidPassword
var invalidTokenErr *authsvc.ErrInvalidTokenType
if errors.As(err, &invalidPasswordErr) {
httpserver.RenderError(w, http.StatusBadRequest, err)
return
}
if errors.As(err, &invalidTokenErr) {
httpserver.RenderError(w, http.StatusBadRequest, err)
return
}
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot reset password: %w", err))
return
}
httpserver.RenderJSON(w, http.StatusOK, ResetPasswordResponse{
Success: true,
})
}
}

View File

@@ -1,122 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"errors"
"fmt"
"net/http"
"time"
"go.gearno.de/kit/log"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/securecookie"
)
func getSessionIDFromCookie(r *http.Request, cookieName string, cookieSecret string, cookieSecure bool) (gid.GID, error) {
cookieValue, err := securecookie.Get(
r,
securecookie.DefaultConfig(
cookieName,
cookieSecret,
cookieSecure,
),
)
if err != nil {
return gid.GID{}, err
}
return gid.ParseGID(cookieValue)
}
func SAMLACSHandler(samlSvc *authsvc.SAMLService, authSvc *authsvc.Service, authzSvc *authz.Service, cookieName string, cookieSecret string, cookieSecure bool, sessionDuration time.Duration, logger *log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
logger.ErrorCtx(ctx, "cannot parse form", log.Error(err))
http.Error(w, "cannot parse form", http.StatusBadRequest)
return
}
if r.FormValue("SAMLResponse") == "" {
logger.WarnCtx(ctx, "missing SAMLResponse")
http.Error(w, "missing SAMLResponse", http.StatusBadRequest)
return
}
userInfo, err := samlSvc.HandleSAMLAssertion(ctx, r)
if err != nil {
logger.ErrorCtx(ctx, "SAML authentication failed", log.Error(err))
http.Error(w, "SAML authentication failed", http.StatusUnauthorized)
return
}
var existingSession *coredata.Session
if existingSessionID, err := getSessionIDFromCookie(r, cookieName, cookieSecret, cookieSecure); err == nil {
if session, err := authSvc.GetSession(ctx, existingSessionID); err == nil {
existingSession = session
}
}
session, user, err := authSvc.ProvisionSAMLUser(
ctx,
userInfo.SAMLConfigID,
userInfo.OrganizationID,
userInfo.Email,
userInfo.FullName,
userInfo.SAMLSubject,
existingSession,
sessionDuration,
)
if err != nil {
var autoSignupDisabledErr *authsvc.ErrSAMLAutoSignupDisabled
if errors.As(err, &autoSignupDisabledErr) {
logger.WarnCtx(ctx, "SAML auto-signup is disabled")
http.Error(w, "User does not exist and auto-signup is disabled for this organization", http.StatusForbidden)
return
}
logger.ErrorCtx(ctx, "cannot provision SAML user", log.Error(err))
http.Error(w, "cannot provision user", http.StatusInternalServerError)
return
}
tenantAuthzSvc := authzSvc.WithTenant(userInfo.OrganizationID.TenantID())
err = tenantAuthzSvc.EnsureSAMLMembership(ctx, user.ID, userInfo.OrganizationID, userInfo.Role)
if err != nil {
logger.ErrorCtx(ctx, "cannot ensure membership", log.Error(err), log.String("user_id", user.ID.String()), log.String("org_id", userInfo.OrganizationID.String()))
http.Error(w, "cannot create membership", http.StatusInternalServerError)
return
}
securecookie.Set(
w,
securecookie.DefaultConfig(
cookieName,
cookieSecret,
cookieSecure,
),
session.ID.String(),
)
logger.InfoCtx(ctx, "SAML login successful", log.String("user_id", user.ID.String()), log.String("org_id", userInfo.OrganizationID.String()))
redirectURL := fmt.Sprintf("/organizations/%s", userInfo.OrganizationID)
http.Redirect(w, r, redirectURL, http.StatusFound)
}
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"fmt"
"net/http"
"net/mail"
authsvc "go.probo.inc/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
)
type (
CheckSSORequest struct {
Email string `json:"email"`
}
CheckSSOResponse struct {
SSOAvailable bool `json:"ssoAvailable"`
SAMLConfigID *string `json:"samlConfigId,omitempty"`
OrganizationID *string `json:"organizationId,omitempty"`
EnforcementPolicy *string `json:"enforcementPolicy,omitempty"`
}
)
func SAMLCheckSSOHandler(authSvc *authsvc.Service, logger *log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CheckSSORequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
if req.Email == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("email is required"))
return
}
if _, err := mail.ParseAddress(req.Email); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid email format"))
return
}
configs, err := authSvc.CheckSSOAvailabilityByEmail(ctx, req.Email)
if err != nil {
logger.ErrorCtx(ctx, "cannot check SSO availability", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot check SSO availability"))
return
}
// No SAML configs found for this domain
if len(configs) == 0 {
httpserver.RenderJSON(w, http.StatusOK, CheckSSOResponse{
SSOAvailable: false,
})
return
}
// Multiple SAML configs found - ambiguous, user must use organization-specific SSO URL
if len(configs) > 1 {
logger.WarnCtx(ctx, "multiple SAML configurations found for domain", log.Int("count", len(configs)))
httpserver.RenderError(w, http.StatusConflict, fmt.Errorf("multiple SSO configurations found for this domain. Please use your organization-specific SSO login URL"))
return
}
// Single SAML config found - return it
config := configs[0]
configIDStr := config.ID.String()
orgIDStr := config.OrganizationID.String()
enforcementPolicy := string(config.EnforcementPolicy)
httpserver.RenderJSON(w, http.StatusOK, CheckSSOResponse{
SSOAvailable: true,
SAMLConfigID: &configIDStr,
OrganizationID: &orgIDStr,
EnforcementPolicy: &enforcementPolicy,
})
}
}

View File

@@ -1,65 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"fmt"
"net/http"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/gid"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
)
func SAMLLoginHandler(samlSvc *authsvc.SAMLService, authSvc *authsvc.Service, logger *log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
samlConfigIDStr := chi.URLParam(r, "samlConfigID")
if samlConfigIDStr == "" {
logger.WarnCtx(ctx, "missing SAML config ID in URL")
http.Error(w, "missing SAML config ID", http.StatusBadRequest)
return
}
samlConfigID, err := gid.ParseGID(samlConfigIDStr)
if err != nil {
logger.ErrorCtx(ctx, "invalid SAML config ID", log.Error(err), log.String("saml_config_id", samlConfigIDStr))
http.Error(w, "invalid SAML config ID", http.StatusBadRequest)
return
}
tenantID := samlConfigID.TenantID()
config, err := authSvc.WithTenant(tenantID).GetSAMLConfigurationByID(ctx, samlConfigID)
if err != nil {
logger.ErrorCtx(ctx, "cannot load SAML configuration", log.Error(err), log.String("saml_config_id", samlConfigID.String()))
http.Error(w, "SAML configuration not found", http.StatusNotFound)
return
}
redirectURL, err := samlSvc.InitiateSAMLLogin(ctx, config.OrganizationID, tenantID, config.EmailDomain)
if err != nil {
logger.ErrorCtx(ctx, "cannot initiate SAML login", log.Error(err), log.String("saml_config_id", samlConfigID.String()), log.String("org_id", config.OrganizationID.String()))
http.Error(w, fmt.Sprintf("SAML login failed: %v", err), http.StatusInternalServerError)
return
}
logger.InfoCtx(ctx, "SAML login initiated", log.String("saml_config_id", samlConfigID.String()), log.String("org_id", config.OrganizationID.String()))
http.Redirect(w, r, redirectURL, http.StatusFound)
}
}

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/securecookie"
)
type (
SignInRequest struct {
Email mail.Addr `json:"email"`
Password string `json:"password"`
}
SignInResponse struct {
User UserResponse `json:"user"`
}
UserResponse struct {
ID gid.GID `json:"id"`
Email mail.Addr `json:"email"`
FullName string `json:"fullName"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
)
func SignInHandler(authSvc *authsvc.Service, cookieName string, cookieSecret string, cookieSecure bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
var existingSession *coredata.Session
if existingSessionID, err := getSessionIDFromCookie(r, cookieName, cookieSecret, cookieSecure); err == nil {
if session, err := authSvc.GetSession(r.Context(), existingSessionID); err == nil {
existingSession = session
}
}
session, user, err := authSvc.SignIn(r.Context(), req.Email, req.Password, existingSession)
if err != nil {
var ErrInvalidCredentials *authsvc.ErrInvalidCredentials
if errors.As(err, &ErrInvalidCredentials) {
httpserver.RenderError(w, http.StatusUnauthorized, err)
return
}
panic(fmt.Errorf("cannot sign in: %w", err))
}
securecookie.Set(
w,
securecookie.DefaultConfig(
cookieName,
cookieSecret,
cookieSecure,
),
session.ID.String(),
)
httpserver.RenderJSON(
w,
http.StatusOK,
SignInResponse{
User: UserResponse{
ID: user.ID,
Email: user.EmailAddress,
FullName: user.FullName,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
},
)
}
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/securecookie"
)
func SignOutHandler(authSvc *authsvc.Service, cookieName string, cookieSecret string, cookieSecure bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sessionID, err := securecookie.Get(r, securecookie.DefaultConfig(
cookieName,
cookieSecret,
cookieSecure,
))
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, err)
return
}
gid, err := gid.ParseGID(sessionID)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, err)
return
}
err = authSvc.SignOut(r.Context(), gid)
if err != nil {
panic(fmt.Errorf("cannot sign out: %w", err))
}
securecookie.Clear(w, securecookie.DefaultConfig(
cookieName,
cookieSecret,
cookieSecure,
))
w.Header().Set("Clear-Site-Data", "*")
httpserver.RenderJSON(w, http.StatusOK, map[string]bool{"success": true})
}
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/securecookie"
)
type (
SignUpRequest struct {
Email mail.Addr `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
}
SignUpResponse struct {
User UserResponse `json:"user"`
}
)
func SignUpHandler(authSvc *authsvc.Service, cookieName string, cookieSecret string, cookieSecure bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignUpRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
user, session, err := authSvc.SignUp(
r.Context(),
req.Email,
req.Password,
req.FullName,
)
if err != nil {
var errUserAlreadyExists *authsvc.ErrUserAlreadyExists
if errors.As(err, &errUserAlreadyExists) {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
return
}
var errSignupDisabled *authsvc.ErrSignupDisabled
if errors.As(err, &errSignupDisabled) {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
return
}
panic(fmt.Errorf("cannot register user: %w", err))
}
securecookie.Set(
w,
securecookie.DefaultConfig(
cookieName,
cookieSecret,
cookieSecure,
),
session.ID.String(),
)
httpserver.RenderJSON(
w,
http.StatusOK,
SignUpResponse{
User: UserResponse{
ID: user.ID,
Email: user.EmailAddress,
FullName: user.FullName,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
},
)
}
}

View File

@@ -1,76 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/securecookie"
)
type (
SignupFromInvitationRequest struct {
Token string `json:"token"`
Password string `json:"password"`
FullName string `json:"fullName"`
}
SignupFromInvitationResponse struct {
}
)
func SignupFromInvitationHandler(authSvc *authsvc.Service, cookieName string, cookieSecret string, cookieSecure bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignupFromInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
user, session, err := authSvc.SignupFromInvitation(r.Context(), req.Token, req.Password, req.FullName)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, err)
return
}
securecookie.Set(
w,
securecookie.DefaultConfig(
cookieName,
cookieSecret,
cookieSecure,
),
session.ID.String(),
)
httpserver.RenderJSON(
w,
http.StatusOK,
SignUpResponse{
User: UserResponse{
ID: user.ID,
Email: user.EmailAddress,
FullName: user.FullName,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
},
)
}
}

View File

@@ -1,159 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"errors"
"net/http"
"strings"
"go.gearno.de/kit/httpserver"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type UpdateUserAPIKeyRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
Organizations []UserAPIKeyOrganizationMembershipRequest `json:"organizations"`
}
func UpdateUserAPIKeyHandler(authSvc *authsvc.Service, authzSvc *authz.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserFromContext(ctx)
var req UpdateUserAPIKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body",
})
return
}
if req.ID == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "user api key id is required",
})
return
}
userAPIKeyID, err := gid.ParseGID(req.ID)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid user api key id",
})
return
}
if req.Name == nil && len(req.Organizations) == 0 {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "at least one field must be provided for update",
})
return
}
if req.Name != nil && *req.Name != "" {
if err := authSvc.UpdateUserAPIKeyName(ctx, userAPIKeyID, user.ID, *req.Name); err != nil {
var errNotFound *coredata.ErrUserAPIKeyNotFound
if errors.As(err, &errNotFound) {
httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{
"error": "user api key not found",
})
return
}
if strings.Contains(err.Error(), "does not belong to user") {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "access denied",
})
return
}
httpserver.RenderJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to update user api key name",
})
return
}
}
if len(req.Organizations) > 0 {
orgInputs := make([]authsvc.UserAPIKeyOrganizationRequest, len(req.Organizations))
for i, org := range req.Organizations {
orgID, err := gid.ParseGID(org.OrganizationID)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid organization id",
})
return
}
// Check if user is an OWNER for this organization
tenantAuthzSvc := authzSvc.WithTenant(orgID.TenantID())
role, err := tenantAuthzSvc.GetUserRoleInOrganization(ctx, user.ID, orgID)
if err != nil {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "user does not have access to this organization",
})
return
}
if role != coredata.MembershipRoleOwner {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "only owners can update API keys for this organization",
})
return
}
orgInputs[i] = authsvc.UserAPIKeyOrganizationRequest{
OrganizationID: orgID,
Role: coredata.APIRole(org.Role),
}
}
memberships, err := authSvc.ValidateAndBuildUserAPIKeyMemberships(ctx, user.ID, orgInputs)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid organizations",
})
return
}
if err := authSvc.UpdateUserAPIKeyMemberships(ctx, userAPIKeyID, user.ID, memberships); err != nil {
var errNotFound *coredata.ErrUserAPIKeyNotFound
if errors.As(err, &errNotFound) {
httpserver.RenderJSON(w, http.StatusNotFound, map[string]string{
"error": "user api key not found",
})
return
}
if strings.Contains(err.Error(), "does not belong to user") {
httpserver.RenderJSON(w, http.StatusForbidden, map[string]string{
"error": "access denied",
})
return
}
httpserver.RenderJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to update user api key",
})
return
}
}
httpserver.RenderJSON(w, http.StatusOK, map[string]string{
"message": "User API key updated successfully",
})
}
}

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package authz
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/server/session"
)
type Config struct {
Auth *authsvc.Service
Authz *authz.Service
Logger *log.Logger
CookieName string
CookieSecret string
CookieSecure bool
}
type Server struct {
router *chi.Mux
}
type ctxKey struct{ name string }
var (
userContextKey = &ctxKey{name: "user"}
)
func NewServer(cfg Config) (*Server, error) {
router := chi.NewRouter()
// Apply authentication middleware to all routes
router.Use(requireAuth(cfg.Auth, cfg.Authz, cfg.CookieName, cfg.CookieSecret, cfg.CookieSecure))
router.Get("/{organizationID}/permissions", PermissionsHandler(cfg.Authz, UserFromContext, cfg.Logger))
return &Server{
router: router,
}, nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
// requireAuth is a middleware that requires authentication
func requireAuth(
authService *authsvc.Service,
authzService *authz.Service,
cookieName string,
cookieSecret string,
cookieSecure bool,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionAuthCfg := session.AuthConfig{
CookieName: cookieName,
CookieSecret: cookieSecret,
CookieSecure: cookieSecure,
}
errorHandler := session.ErrorHandler{
OnCookieError: func(err error) {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
},
OnTenantError: func(err error) {
panic(fmt.Errorf("cannot list tenants for user: %w", err))
},
}
authResult := session.TryAuth(ctx, w, r, authService, authzService, sessionAuthCfg, errorHandler)
if authResult == nil {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
return
}
ctx = context.WithValue(ctx, userContextKey, authResult.User)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func UserFromContext(ctx context.Context) *coredata.User {
user, _ := ctx.Value(userContextKey).(*coredata.User)
return user
}

View File

@@ -1,76 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package authz
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
// PermissionsHandler returns permissions for the current user's role in an organization
// It uses the centralized permissions map
func PermissionsHandler(
authzService *authz.Service,
userFromContext func(ctx context.Context) *coredata.User,
logger *log.Logger,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
orgIDStr := chi.URLParam(r, "organizationID")
if orgIDStr == "" {
http.Error(w, "organizationID parameter required", http.StatusBadRequest)
return
}
orgID, err := gid.ParseGID(orgIDStr)
if err != nil {
http.Error(w, "invalid organizationID", http.StatusBadRequest)
return
}
user := userFromContext(ctx)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tenantAuthzSvc := authzService.WithTenant(orgID.TenantID())
memberRole, err := tenantAuthzSvc.GetUserRoleInOrganization(ctx, user.ID, orgID)
if err != nil {
http.Error(w, fmt.Sprintf("cannot get user role: %v", err), http.StatusInternalServerError)
return
}
userRole := authz.Role(memberRole.String())
permissions := authz.GetPermissionsByRole(userRole)
response := map[string]any{
"permissions": permissions,
"role": memberRole.String(),
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package gqlutils
import (
"context"
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"go.gearno.de/kit/log"
)
type Handler struct {
gqlhandler *handler.Server
}
var (
mb int64 = 1024 * 1024
postTransport = transport.POST{}
optionsTransport = transport.Options{}
multipartTransport = transport.MultipartForm{
MaxMemory: 32 * mb,
MaxUploadSize: 50 * mb,
}
introspectionExtension = extension.Introspection{}
)
func NewHandler[S graphql.ExecutableSchema](executableSchema S, logger *log.Logger) *Handler {
handler := handler.New(executableSchema)
handler.AddTransport(postTransport)
handler.AddTransport(optionsTransport)
handler.AddTransport(multipartTransport)
handler.Use(introspectionExtension)
handler.Use(NewTracingExtension(logger))
handler.SetRecoverFunc(RecoverFunc)
return &Handler{gqlhandler: handler}
}
func (gqlh *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
gqlh.gqlhandler.ServeHTTP(w, r)
}
func (gqlh *Handler) Use(extension graphql.HandlerExtension) {
gqlh.gqlhandler.Use(extension)
}
func (gqlh *Handler) AroundOperations(f func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler) {
gqlh.gqlhandler.AroundOperations(f)
}

View File

@@ -22,8 +22,7 @@ import (
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/validator"
)
@@ -32,24 +31,25 @@ func RecoverFunc(ctx context.Context, err any) error {
return gqlErr
}
var errSAMLRequired auth.ErrSAMLAuthRequired
if errors.As(asError(err), &errSAMLRequired) {
return AuthenticationRequired(map[string]any{
"requiresSaml": true,
"redirectUrl": errSAMLRequired.RedirectURL,
"samlConfigId": errSAMLRequired.ConfigID.String(),
"organizationId": errSAMLRequired.OrganizationID.String(),
})
}
// TODO: multi session here
// var errSAMLRequired iam.ErrSAMLAuthRequired
// if errors.As(asError(err), &errSAMLRequired) {
// return AuthenticationRequired(map[string]any{
// "requiresSaml": true,
// "redirectUrl": errSAMLRequired.RedirectURL,
// "samlConfigId": errSAMLRequired.ConfigID.String(),
// "organizationId": errSAMLRequired.OrganizationID.String(),
// })
// }
var errPasswordRequired auth.ErrPasswordAuthRequired
if errors.As(asError(err), &errPasswordRequired) {
return AuthenticationRequired(map[string]any{
"requiresSaml": false,
"redirectUrl": errPasswordRequired.RedirectURL,
"organizationId": errPasswordRequired.OrganizationID.String(),
})
}
// var errPasswordRequired iam.ErrPasswordAuthRequired
// if errors.As(asError(err), &errPasswordRequired) {
// return AuthenticationRequired(map[string]any{
// "requiresSaml": false,
// "redirectUrl": errPasswordRequired.RedirectURL,
// "organizationId": errPasswordRequired.OrganizationID.String(),
// })
// }
var errValidations validator.ValidationErrors
if errors.As(asError(err), &errValidations) {
@@ -72,12 +72,12 @@ func RecoverFunc(ctx context.Context, err any) error {
return gqlErrors
}
var tenantAccessErr *authz.TenantAccessError
var tenantAccessErr *iam.TenantAccessError
if errTyped, ok := err.(error); ok && errors.As(errTyped, &tenantAccessErr) {
return Unauthorized()
}
var permissionDeniedErr *authz.PermissionDeniedError
var permissionDeniedErr *iam.ErrInsufficientPermissions
if errTyped, ok := err.(error); ok && errors.As(errTyped, &permissionDeniedErr) {
return Forbidden(permissionDeniedErr)
}

View File

@@ -23,52 +23,40 @@ import (
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/agents"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/saferedirect"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api"
trust_v1 "go.probo.inc/probo/pkg/server/api/trust/v1"
auth_server "go.probo.inc/probo/pkg/server/auth"
authz_server "go.probo.inc/probo/pkg/server/authz"
"go.probo.inc/probo/pkg/server/trust"
"go.probo.inc/probo/pkg/server/web"
trust_web "go.probo.inc/probo/pkg/server/trust"
console_web "go.probo.inc/probo/pkg/server/web"
"go.probo.inc/probo/pkg/slack"
trust_pkg "go.probo.inc/probo/pkg/trust"
"go.probo.inc/probo/pkg/trust"
)
type Config struct {
BaseURL *baseurl.BaseURL
AllowedOrigins []string
ExtraHeaderFields map[string]string
Probo *probo.Service
Auth *auth.Service
Authz *authz.Service
Trust *trust_pkg.Service
IAM *iam.Service
Trust *trust.Service
Slack *slack.Service
SAML *auth.SAMLService
ConsoleAuth api.ConsoleAuthConfig
TrustAuth api.TrustAuthConfig
MCPConfig api.MCPConfig
Cookie securecookie.Config
ConnectorRegistry *connector.ConnectorRegistry
Agent *agents.Agent
SafeRedirect *saferedirect.SafeRedirect
CustomDomainCname string
FileManager *filemanager.Service
PGClient *pg.Client
Logger *log.Logger
}
type Server struct {
apiServer *api.Server
webServer *web.Server
trustServer *trust.Server
authServer *auth_server.Server
authzServer *authz_server.Server
consoleWebServer *console_web.Server
trustWebServer *trust_web.Server
router *chi.Mux
extraHeaderFields map[string]string
proboService *probo.Service
@@ -77,60 +65,29 @@ type Server struct {
func NewServer(cfg Config) (*Server, error) {
apiCfg := api.Config{
BaseURL: cfg.BaseURL,
AllowedOrigins: cfg.AllowedOrigins,
Probo: cfg.Probo,
Auth: cfg.Auth,
Authz: cfg.Authz,
IAM: cfg.IAM,
Trust: cfg.Trust,
Slack: cfg.Slack,
SAML: cfg.SAML,
ConsoleAuth: cfg.ConsoleAuth,
TrustAuth: cfg.TrustAuth,
MCPConfig: cfg.MCPConfig,
Cookie: cfg.Cookie,
ConnectorRegistry: cfg.ConnectorRegistry,
SafeRedirect: cfg.SafeRedirect,
CustomDomainCname: cfg.CustomDomainCname,
Logger: cfg.Logger.Named("api"),
}
apiServer, err := api.NewServer(apiCfg)
if err != nil {
return nil, err
}
webServer, err := web.NewServer()
consoleWebServer, err := console_web.NewServer()
if err != nil {
return nil, err
}
trustServer, err := trust.NewServer()
if err != nil {
return nil, err
}
authServer, err := auth_server.NewServer(auth_server.Config{
Auth: cfg.Auth,
Authz: cfg.Authz,
SAML: cfg.SAML,
CookieName: cfg.ConsoleAuth.CookieName,
CookieDomain: cfg.ConsoleAuth.CookieDomain,
SessionDuration: cfg.ConsoleAuth.SessionDuration,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
CookieSecure: cfg.ConsoleAuth.CookieSecure,
FileManager: cfg.FileManager,
Logger: cfg.Logger.Named("auth"),
})
if err != nil {
return nil, err
}
authzServer, err := authz_server.NewServer(authz_server.Config{
Auth: cfg.Auth,
Authz: cfg.Authz,
Logger: cfg.Logger.Named("authz"),
CookieName: cfg.ConsoleAuth.CookieName,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
CookieSecure: cfg.ConsoleAuth.CookieSecure,
})
trustWebServer, err := trust_web.NewServer()
if err != nil {
return nil, err
}
@@ -139,10 +96,8 @@ func NewServer(cfg Config) (*Server, error) {
server := &Server{
apiServer: apiServer,
webServer: webServer,
trustServer: trustServer,
authServer: authServer,
authzServer: authzServer,
consoleWebServer: consoleWebServer,
trustWebServer: trustWebServer,
router: router,
extraHeaderFields: cfg.ExtraHeaderFields,
proboService: cfg.Probo,
@@ -156,8 +111,6 @@ func NewServer(cfg Config) (*Server, error) {
func (s *Server) setupRoutes() {
s.router.Mount("/api", s.apiServer)
s.router.Mount("/connect", s.authServer)
s.router.Mount("/authz", s.authzServer)
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
r.Use(s.loadTrustCenterBySlugOrID)
@@ -165,7 +118,7 @@ func (s *Server) setupRoutes() {
r.Mount("/", s.trustCenterRouter())
})
s.router.Mount("/", s.webServer)
s.router.Mount("/", s.consoleWebServer)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -286,7 +239,6 @@ func (s *Server) loadTrustCenterByDomain(next http.Handler) http.Handler {
}
func (s *Server) addTrustCenterToContext(ctx context.Context, tenantID, organizationID interface{}) context.Context {
ctx = context.WithValue(ctx, trust_v1.CustomDomainTenantIDKey, tenantID)
ctx = context.WithValue(ctx, trust_v1.CustomDomainOrganizationIDKey, organizationID)
return ctx
}
@@ -313,8 +265,8 @@ func (s *Server) stripTrustPrefix(next http.Handler) http.Handler {
func (s *Server) trustCenterRouter() chi.Router {
r := chi.NewRouter()
r.Mount("/api/trust/v1", s.apiServer.TrustAPIHandler())
r.Handle("/*", s.trustServer)
r.Mount("/api/trust/v1", s.apiServer.CompliancePageHandler())
r.Handle("/*", s.trustWebServer)
return r
}

View File

@@ -1,149 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package session
import (
"context"
"errors"
"net/http"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/securecookie"
)
type AuthConfig struct {
CookieName string
CookieSecret string
CookieSecure bool
}
type AuthResult struct {
Session *coredata.Session
User *coredata.User
TenantIDs []gid.TenantID
AuthErrors map[gid.TenantID]error // Maps tenant ID to authentication error
}
type ErrorHandler struct {
OnCookieError func(err error)
OnParseError func(w http.ResponseWriter, authCfg AuthConfig)
OnSessionError func(w http.ResponseWriter, authCfg AuthConfig)
OnUserError func(w http.ResponseWriter, authCfg AuthConfig)
OnTenantError func(err error)
}
func TryAuth(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
authSvc *auth.Service,
authzSvc *authz.Service,
authCfg AuthConfig,
errorHandler ErrorHandler,
) *AuthResult {
cookieValue, err := securecookie.Get(r, securecookie.DefaultConfig(
authCfg.CookieName,
authCfg.CookieSecret,
authCfg.CookieSecure,
))
if err != nil {
if !errors.Is(err, securecookie.ErrCookieNotFound) && errorHandler.OnCookieError != nil {
errorHandler.OnCookieError(err)
}
return nil
}
sessionID, err := gid.ParseGID(cookieValue)
if err != nil {
if errorHandler.OnParseError != nil {
errorHandler.OnParseError(w, authCfg)
}
return nil
}
session, err := authSvc.GetSession(ctx, sessionID)
if err != nil {
if errorHandler.OnSessionError != nil {
errorHandler.OnSessionError(w, authCfg)
}
return nil
}
user, err := authSvc.GetUserBySession(ctx, sessionID)
if err != nil {
if errorHandler.OnUserError != nil {
errorHandler.OnUserError(w, authCfg)
}
return nil
}
organizations, err := authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil {
if errorHandler.OnTenantError != nil {
errorHandler.OnTenantError(err)
}
return nil
}
// Validate organization access based on authentication requirements
// Only include organizations the user has proper authentication for
allowedTenantIDs := make([]gid.TenantID, 0, len(organizations))
authErrors := make(map[gid.TenantID]error)
// Extract organization IDs for batch check
orgIDs := make([]gid.GID, len(organizations))
for i, org := range organizations {
orgIDs[i] = org.ID
}
// Batch check access to all organizations in a single query
accessResults, err := authSvc.CheckOrganizationAccess(ctx, user, orgIDs, session)
if err != nil {
if errorHandler.OnTenantError != nil {
errorHandler.OnTenantError(err)
}
return nil
}
// Process results
for _, org := range organizations {
result := accessResults[org.ID]
if result.Allowed {
// User has proper authentication for this org
allowedTenantIDs = append(allowedTenantIDs, org.ID.TenantID())
} else {
// Store the authentication error for later use
authErrors[org.ID.TenantID()] = result.ToError(authSvc.BaseURL())
}
}
return &AuthResult{
Session: session,
User: user,
TenantIDs: allowedTenantIDs,
AuthErrors: authErrors,
}
}
func ClearCookie(w http.ResponseWriter, authCfg AuthConfig) {
securecookie.Clear(w, securecookie.DefaultConfig(
authCfg.CookieName,
authCfg.CookieSecret,
authCfg.CookieSecure,
))
}