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

@@ -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)
}