Rewrite identity and access management
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user