Add tools and update authenticztion and RBAC
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -21,7 +21,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -44,6 +43,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/saferedirect"
|
"go.probo.inc/probo/pkg/saferedirect"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
"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/gqlutils"
|
||||||
"go.probo.inc/probo/pkg/server/session"
|
"go.probo.inc/probo/pkg/server/session"
|
||||||
"go.probo.inc/probo/pkg/statelesstoken"
|
"go.probo.inc/probo/pkg/statelesstoken"
|
||||||
@@ -69,18 +69,10 @@ type (
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctxKey struct{ name string }
|
ctxKey struct{ name string }
|
||||||
|
|
||||||
userTenantAccess struct {
|
|
||||||
tenantIDs []gid.TenantID
|
|
||||||
authErrors map[gid.TenantID]error
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
sessionContextKey = &ctxKey{name: "session"}
|
sessionContextKey = &ctxKey{name: "session"}
|
||||||
userContextKey = &ctxKey{name: "user"}
|
|
||||||
userTenantContextKey = &ctxKey{name: "user_tenants"}
|
|
||||||
userAPIKeyContextKey = &ctxKey{name: "user_api_key"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SessionFromContext(ctx context.Context) *coredata.Session {
|
func SessionFromContext(ctx context.Context) *coredata.Session {
|
||||||
@@ -89,13 +81,11 @@ func SessionFromContext(ctx context.Context) *coredata.Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UserFromContext(ctx context.Context) *coredata.User {
|
func UserFromContext(ctx context.Context) *coredata.User {
|
||||||
user, _ := ctx.Value(userContextKey).(*coredata.User)
|
return serverauth.UserFromContext(ctx)
|
||||||
return user
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserAPIKeyFromContext(ctx context.Context) *coredata.UserAPIKey {
|
func UserAPIKeyFromContext(ctx context.Context) *coredata.UserAPIKey {
|
||||||
userAPIKey, _ := ctx.Value(userAPIKeyContextKey).(*coredata.UserAPIKey)
|
return serverauth.UserAPIKeyFromContext(ctx)
|
||||||
return userAPIKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMux(
|
func NewMux(
|
||||||
@@ -377,7 +367,7 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
if authCtx := tryAPIKeyAuth(ctx, r, authSvc, authzSvc); authCtx != nil {
|
if authCtx := serverauth.AuthenticateWithAPIKey(ctx, r, authSvc, authzSvc); authCtx != nil {
|
||||||
next(w, r.WithContext(authCtx))
|
next(w, r.WithContext(authCtx))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -413,10 +403,10 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
|
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
|
||||||
ctx = context.WithValue(ctx, userContextKey, authResult.User)
|
ctx = context.WithValue(ctx, serverauth.UserContextKey, authResult.User)
|
||||||
ctx = context.WithValue(ctx, userTenantContextKey, &userTenantAccess{
|
ctx = context.WithValue(ctx, serverauth.UserTenantContextKey, &serverauth.UserTenantAccess{
|
||||||
tenantIDs: authResult.TenantIDs,
|
TenantIDs: authResult.TenantIDs,
|
||||||
authErrors: authResult.AuthErrors,
|
AuthErrors: authResult.AuthErrors,
|
||||||
})
|
})
|
||||||
|
|
||||||
next(w, r.WithContext(ctx))
|
next(w, r.WithContext(ctx))
|
||||||
@@ -428,43 +418,6 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryAPIKeyAuth(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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService {
|
func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService {
|
||||||
return GetTenantService(ctx, r.proboSvc, tenantID)
|
return GetTenantService(ctx, r.proboSvc, tenantID)
|
||||||
}
|
}
|
||||||
@@ -486,38 +439,20 @@ func UnwrapOmittable[T any](field graphql.Omittable[T]) *T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetTenantService(ctx context.Context, proboSvc *probo.Service, tenantID gid.TenantID) *probo.TenantService {
|
func GetTenantService(ctx context.Context, proboSvc *probo.Service, tenantID gid.TenantID) *probo.TenantService {
|
||||||
validateTenantAccess(ctx, tenantID)
|
serverauth.RequireTenantAccess(ctx, tenantID)
|
||||||
return proboSvc.WithTenant(tenantID)
|
return proboSvc.WithTenant(tenantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
|
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
|
||||||
validateTenantAccess(ctx, tenantID)
|
serverauth.RequireTenantAccess(ctx, tenantID)
|
||||||
return authzSvc.WithTenant(tenantID)
|
return authzSvc.WithTenant(tenantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTenantAuthService(ctx context.Context, authSvc *auth.Service, tenantID gid.TenantID) *auth.TenantAuthService {
|
func GetTenantAuthService(ctx context.Context, authSvc *auth.Service, tenantID gid.TenantID) *auth.TenantAuthService {
|
||||||
validateTenantAccess(ctx, tenantID)
|
serverauth.RequireTenantAccess(ctx, tenantID)
|
||||||
return authSvc.WithTenant(tenantID)
|
return authSvc.WithTenant(tenantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
|
|
||||||
access, _ := ctx.Value(userTenantContextKey).(*userTenantAccess)
|
|
||||||
|
|
||||||
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"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action authz.Action) {
|
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action authz.Action) {
|
||||||
user := UserFromContext(ctx)
|
user := UserFromContext(ctx)
|
||||||
apiKey := UserAPIKeyFromContext(ctx)
|
apiKey := UserAPIKeyFromContext(ctx)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ package console_v1
|
|||||||
|
|
||||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
// 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.
|
// 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.76
|
// Code generated by github.com/99designs/gqlgen version v0.17.83
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
||||||
|
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1451,9 +1452,9 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Append tenant to allowed one
|
// Append tenant to allowed one
|
||||||
access, _ := ctx.Value(userTenantContextKey).(*userTenantAccess)
|
access := serverauth.UserTenantAccessFromContext(ctx)
|
||||||
if access != nil {
|
if access != nil {
|
||||||
access.tenantIDs = append(access.tenantIDs, organization.ID.TenantID())
|
access.TenantIDs = append(access.TenantIDs, organization.ID.TenantID())
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.CreateOrganizationPayload{
|
return &types.CreateOrganizationPayload{
|
||||||
|
|||||||
76
pkg/server/api/mcp/mcputils/recovery.go
Normal file
76
pkg/server/api/mcp/mcputils/recovery.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// 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 mcputils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.probo.inc/probo/pkg/authz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecoveryMiddleware creates a middleware that recovers from panics in MCP method handlers.
|
||||||
|
// It converts panics to errors, handling authz errors appropriately.
|
||||||
|
func RecoveryMiddleware(logger *log.Logger) func(mcp.MethodHandler) mcp.MethodHandler {
|
||||||
|
return func(next mcp.MethodHandler) mcp.MethodHandler {
|
||||||
|
return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = convertPanicToError(ctx, logger, r)
|
||||||
|
result = nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
result, err = next(ctx, method, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertPanicToError converts a panic value to an error, handling authz errors appropriately.
|
||||||
|
func convertPanicToError(ctx context.Context, logger *log.Logger, panicValue any) error {
|
||||||
|
if panicValue == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle TenantAccessError - convert to "not authorized" error
|
||||||
|
var tenantAccessErr *authz.TenantAccessError
|
||||||
|
if errTyped, ok := panicValue.(error); ok && errors.As(errTyped, &tenantAccessErr) {
|
||||||
|
return fmt.Errorf("not authorized: %s", tenantAccessErr.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle PermissionDeniedError - convert to "permission denied" error
|
||||||
|
var permissionDeniedErr *authz.PermissionDeniedError
|
||||||
|
if errTyped, ok := panicValue.(error); ok && errors.As(errTyped, &permissionDeniedErr) {
|
||||||
|
return fmt.Errorf("permission denied: %s", permissionDeniedErr.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other errors - return as-is
|
||||||
|
if err, ok := panicValue.(error); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log unexpected panics with stack trace
|
||||||
|
logger.ErrorCtx(ctx, "unexpected panic in MCP method handler",
|
||||||
|
log.Any("panic", panicValue),
|
||||||
|
log.String("stack", string(debug.Stack())),
|
||||||
|
)
|
||||||
|
|
||||||
|
return fmt.Errorf("internal server error")
|
||||||
|
}
|
||||||
@@ -16,12 +16,11 @@ package mcp_v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/auth"
|
"go.probo.inc/probo/pkg/auth"
|
||||||
"go.probo.inc/probo/pkg/authz"
|
"go.probo.inc/probo/pkg/authz"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WithMCPAuth wraps an HTTP handler with MCP authentication middleware
|
// WithMCPAuth wraps an HTTP handler with MCP authentication middleware
|
||||||
@@ -44,71 +43,27 @@ func WithMCPAuth(
|
|||||||
log.String("path", r.URL.Path),
|
log.String("path", r.URL.Path),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Extract API key from Authorization header
|
// Authenticate using API key from shared function
|
||||||
authHeader := r.Header.Get("Authorization")
|
authCtx := serverauth.AuthenticateWithAPIKey(ctx, r, authSvc, authzSvc)
|
||||||
if authHeader == "" {
|
if authCtx == nil {
|
||||||
logger.WarnCtx(ctx, "MCP auth: missing Authorization header",
|
logger.WarnCtx(ctx, "MCP auth: authentication required",
|
||||||
log.String("correlation_id", correlationID),
|
log.String("correlation_id", correlationID),
|
||||||
)
|
)
|
||||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect "Bearer <token>" format
|
user := serverauth.UserFromContext(authCtx)
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
userAPIKey := serverauth.UserAPIKeyFromContext(authCtx)
|
||||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
tenantAccess := serverauth.UserTenantAccessFromContext(authCtx)
|
||||||
logger.WarnCtx(ctx, "MCP auth: invalid Authorization header format",
|
|
||||||
log.String("correlation_id", correlationID),
|
|
||||||
)
|
|
||||||
http.Error(w, "invalid authorization header", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKeyToken := parts[1]
|
logger.InfoCtx(authCtx, "MCP authentication successful",
|
||||||
|
|
||||||
// Validate the API key
|
|
||||||
user, userAPIKey, err := authSvc.ValidateUserAPIKey(ctx, apiKeyToken)
|
|
||||||
if err != nil {
|
|
||||||
logger.WarnCtx(ctx, "MCP auth: invalid API key",
|
|
||||||
log.Error(err),
|
|
||||||
log.String("correlation_id", correlationID),
|
|
||||||
)
|
|
||||||
http.Error(w, "invalid api key", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get organizations for this API key through the service layer
|
|
||||||
organizations, err := authzSvc.GetAllOrganizationsForUserAPIKeyId(ctx, userAPIKey.ID)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCtx(ctx, "MCP auth: failed to load API key organizations",
|
|
||||||
log.Error(err),
|
|
||||||
log.String("correlation_id", correlationID),
|
|
||||||
)
|
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract tenant IDs from organizations
|
|
||||||
tenantIDs := make([]gid.TenantID, 0, len(organizations))
|
|
||||||
for _, org := range organizations {
|
|
||||||
tenantIDs = append(tenantIDs, org.ID.TenantID())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create MCP context with user and accessible tenants
|
|
||||||
mcpCtx := &MCPContext{
|
|
||||||
UserID: user.ID,
|
|
||||||
TenantIDs: tenantIDs,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx = ContextWithMCPContext(ctx, mcpCtx)
|
|
||||||
|
|
||||||
logger.InfoCtx(ctx, "MCP authentication successful",
|
|
||||||
log.String("correlation_id", correlationID),
|
log.String("correlation_id", correlationID),
|
||||||
log.String("user_id", user.ID.String()),
|
log.String("user_id", user.ID.String()),
|
||||||
log.String("api_key_id", userAPIKey.ID.String()),
|
log.String("api_key_id", userAPIKey.ID.String()),
|
||||||
log.Int("accessible_tenants", len(tenantIDs)),
|
log.Int("accessible_tenants", len(tenantAccess.TenantIDs)),
|
||||||
)
|
)
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(authCtx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
package mcp_v1
|
package mcp_v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/auth"
|
"go.probo.inc/probo/pkg/auth"
|
||||||
"go.probo.inc/probo/pkg/authz"
|
"go.probo.inc/probo/pkg/authz"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
|
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
@@ -15,3 +19,26 @@ type Resolver struct {
|
|||||||
authzSvc *authz.Service
|
authzSvc *authz.Service
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action authz.Action) {
|
||||||
|
user := serverauth.UserFromContext(ctx)
|
||||||
|
apiKey := serverauth.UserAPIKeyFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
panic(&authz.TenantAccessError{Message: "authentication required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
authzSvc := r.AuthzService(ctx, entityID.TenantID())
|
||||||
|
err := authzSvc.Authorize(ctx, user, apiKey, 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,17 +9,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"go.probo.inc/probo/pkg/authz"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/mcp/v1/types"
|
"go.probo.inc/probo/pkg/server/api/mcp/v1/types"
|
||||||
|
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListOrganizationsTool handles the listOrganizations tool
|
// ListOrganizationsTool handles the listOrganizations tool
|
||||||
// List all organizations the user has access to
|
// 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) {
|
func (r *Resolver) ListOrganizationsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListOrganizationsInput) (*mcp.CallToolResult, types.ListOrganizationsOutput, error) {
|
||||||
mcpCtx := MCPContextFromContext(ctx)
|
user := serverauth.UserFromContext(ctx)
|
||||||
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, mcpCtx.UserID)
|
if user == nil {
|
||||||
|
return nil, types.ListOrganizationsOutput{}, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ListOrganizationsOutput{}, fmt.Errorf("failed to list organizations: %w", err)
|
return nil, types.ListOrganizationsOutput{}, fmt.Errorf("failed to list organizations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -38,7 +44,9 @@ func (r *Resolver) ListOrganizationsTool(ctx context.Context, req *mcp.CallToolR
|
|||||||
// ListVendorsTool handles the listVendors tool
|
// ListVendorsTool handles the listVendors tool
|
||||||
// List all vendors for the organization
|
// List all vendors for the organization
|
||||||
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
|
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
|
||||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListVendors)
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.OrganizationID)
|
||||||
|
|
||||||
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
|
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
|
||||||
Field: coredata.VendorOrderFieldCreatedAt,
|
Field: coredata.VendorOrderFieldCreatedAt,
|
||||||
@@ -69,7 +77,9 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
|
|||||||
// AddVendorTool handles the addVendor tool
|
// AddVendorTool handles the addVendor tool
|
||||||
// Add a new vendor to the organization
|
// 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) {
|
func (r *Resolver) AddVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddVendorInput) (*mcp.CallToolResult, types.AddVendorOutput, error) {
|
||||||
svc := r.ProboService(ctx, input.OrganizationID.TenantID())
|
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateAsset)
|
||||||
|
|
||||||
|
svc := r.ProboService(ctx, input.OrganizationID)
|
||||||
|
|
||||||
vendor, err := svc.Vendors.Create(
|
vendor, err := svc.Vendors.Create(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -106,11 +116,14 @@ func (r *Resolver) AddVendorTool(ctx context.Context, req *mcp.CallToolRequest,
|
|||||||
// UpdateVendorTool handles the updateVendor tool
|
// UpdateVendorTool handles the updateVendor tool
|
||||||
// Update an existing vendor
|
// Update an existing vendor
|
||||||
func (r *Resolver) UpdateVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateVendorInput) (*mcp.CallToolResult, types.UpdateVendorOutput, error) {
|
func (r *Resolver) UpdateVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateVendorInput) (*mcp.CallToolResult, types.UpdateVendorOutput, error) {
|
||||||
|
|
||||||
return nil, types.UpdateVendorOutput{}, fmt.Errorf("updateVendor not implemented")
|
return nil, types.UpdateVendorOutput{}, fmt.Errorf("updateVendor not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) ListPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListPeopleInput) (*mcp.CallToolResult, types.ListPeopleOutput, error) {
|
func (r *Resolver) ListPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListPeopleInput) (*mcp.CallToolResult, types.ListPeopleOutput, error) {
|
||||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListPeople)
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.OrganizationID)
|
||||||
|
|
||||||
pageOrderBy := page.OrderBy[coredata.PeopleOrderField]{
|
pageOrderBy := page.OrderBy[coredata.PeopleOrderField]{
|
||||||
Field: coredata.PeopleOrderFieldCreatedAt,
|
Field: coredata.PeopleOrderFieldCreatedAt,
|
||||||
@@ -139,7 +152,9 @@ 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) {
|
func (r *Resolver) GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetPeopleInput) (*mcp.CallToolResult, types.GetPeopleOutput, error) {
|
||||||
prb := r.ProboService(ctx, input.ID.TenantID())
|
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.ID)
|
||||||
|
|
||||||
people, err := prb.Peoples.Get(ctx, input.ID)
|
people, err := prb.Peoples.Get(ctx, input.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,3 +165,30 @@ func (r *Resolver) GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest,
|
|||||||
People: types.NewPeople(people),
|
People: types.NewPeople(people),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
svc := r.ProboService(ctx, input.OrganizationID)
|
||||||
|
|
||||||
|
people, err := svc.Peoples.Create(
|
||||||
|
ctx,
|
||||||
|
probo.CreatePeopleRequest{
|
||||||
|
OrganizationID: input.OrganizationID,
|
||||||
|
FullName: input.FullName,
|
||||||
|
PrimaryEmailAddress: input.PrimaryEmailAddress,
|
||||||
|
AdditionalEmailAddresses: input.AdditionalEmailAddresses,
|
||||||
|
Kind: input.Kind,
|
||||||
|
Position: input.Position,
|
||||||
|
ContractStartDate: input.ContractStartDate,
|
||||||
|
ContractEndDate: input.ContractEndDate,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.AddPeopleOutput{}, fmt.Errorf("failed to create people: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.AddPeopleOutput{
|
||||||
|
People: types.NewPeople(people),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type ResolverInterface interface {
|
|||||||
AddVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddVendorInput) (*mcp.CallToolResult, types.AddVendorOutput, error)
|
AddVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddVendorInput) (*mcp.CallToolResult, types.AddVendorOutput, error)
|
||||||
UpdateVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateVendorInput) (*mcp.CallToolResult, types.UpdateVendorOutput, error)
|
UpdateVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateVendorInput) (*mcp.CallToolResult, types.UpdateVendorOutput, error)
|
||||||
GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetPeopleInput) (*mcp.CallToolResult, types.GetPeopleOutput, error)
|
GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetPeopleInput) (*mcp.CallToolResult, types.GetPeopleOutput, error)
|
||||||
|
AddPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddPeopleInput) (*mcp.CallToolResult, types.AddPeopleOutput, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new MCP server instance with all handlers registered.
|
// New creates a new MCP server instance with all handlers registered.
|
||||||
@@ -95,4 +96,14 @@ func registerToolHandlers(server *mcp.Server, resolver ResolverInterface) {
|
|||||||
},
|
},
|
||||||
resolver.GetPeopleTool,
|
resolver.GetPeopleTool,
|
||||||
)
|
)
|
||||||
|
mcp.AddTool(
|
||||||
|
server,
|
||||||
|
&mcp.Tool{
|
||||||
|
Name: "addPeople",
|
||||||
|
Description: "Add a new people to the organization",
|
||||||
|
InputSchema: types.AddPeopleToolInputSchema,
|
||||||
|
OutputSchema: types.AddPeopleToolOutputSchema,
|
||||||
|
},
|
||||||
|
resolver.AddPeopleTool,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,51 @@ components:
|
|||||||
people:
|
people:
|
||||||
$ref: "#/components/schemas/People"
|
$ref: "#/components/schemas/People"
|
||||||
|
|
||||||
|
AddPeopleInput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- organization_id
|
||||||
|
- full_name
|
||||||
|
- primary_email_address
|
||||||
|
- kind
|
||||||
|
properties:
|
||||||
|
organization_id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: Organization ID
|
||||||
|
full_name:
|
||||||
|
type: string
|
||||||
|
description: Full name
|
||||||
|
primary_email_address:
|
||||||
|
type: string
|
||||||
|
description: Primary email address
|
||||||
|
additional_email_addresses:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Additional email addresses
|
||||||
|
kind:
|
||||||
|
$ref: "#/components/schemas/PeopleKind"
|
||||||
|
description: People kind
|
||||||
|
position:
|
||||||
|
type: string
|
||||||
|
description: Position
|
||||||
|
contract_start_date:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Contract start date
|
||||||
|
contract_end_date:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Contract end date
|
||||||
|
|
||||||
|
AddPeopleOutput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- people
|
||||||
|
properties:
|
||||||
|
people:
|
||||||
|
$ref: "#/components/schemas/People"
|
||||||
|
|
||||||
tools:
|
tools:
|
||||||
- name: listOrganizations
|
- name: listOrganizations
|
||||||
description: List all organizations the user has access to
|
description: List all organizations the user has access to
|
||||||
@@ -395,3 +440,10 @@ tools:
|
|||||||
$ref: "#/components/schemas/GetPeopleInput"
|
$ref: "#/components/schemas/GetPeopleInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/GetPeopleOutput"
|
$ref: "#/components/schemas/GetPeopleOutput"
|
||||||
|
- name: addPeople
|
||||||
|
description: Add a new people to the organization
|
||||||
|
readonly: false
|
||||||
|
inputSchema:
|
||||||
|
$ref: "#/components/schemas/AddPeopleInput"
|
||||||
|
outputSchema:
|
||||||
|
$ref: "#/components/schemas/AddPeopleOutput"
|
||||||
|
|||||||
@@ -15,10 +15,7 @@
|
|||||||
package mcp_v1
|
package mcp_v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.probo.inc/probo/pkg/gid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -27,28 +24,8 @@ type (
|
|||||||
RequestTimeout time.Duration
|
RequestTimeout time.Duration
|
||||||
MaxRequestSize int64
|
MaxRequestSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
MCPContext struct {
|
|
||||||
UserID gid.GID
|
|
||||||
TenantIDs []gid.TenantID
|
|
||||||
}
|
|
||||||
|
|
||||||
ctxKey struct{ name string }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
mcpContextKey = &ctxKey{name: "mcp_context"}
|
|
||||||
)
|
|
||||||
|
|
||||||
func MCPContextFromContext(ctx context.Context) *MCPContext {
|
|
||||||
mcpCtx, _ := ctx.Value(mcpContextKey).(*MCPContext)
|
|
||||||
return mcpCtx
|
|
||||||
}
|
|
||||||
|
|
||||||
func ContextWithMCPContext(ctx context.Context, mcpCtx *MCPContext) context.Context {
|
|
||||||
return context.WithValue(ctx, mcpContextKey, mcpCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Version: "1.0.0",
|
Version: "1.0.0",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
|
|
||||||
// Tool input schemas
|
// Tool input schemas
|
||||||
var (
|
var (
|
||||||
|
AddPeopleToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["organization_id","full_name","primary_email_address","kind"],"properties":{"additional_email_addresses":{"type":"array","description":"Additional email addresses","items":{"type":"string"}},"contract_end_date":{"type":"string","description":"Contract end date","format":"date-time"},"contract_start_date":{"type":"string","description":"Contract start date","format":"date-time"},"full_name":{"type":"string","description":"Full name"},"kind":{"type":"string","enum":["EMPLOYEE","CONTRACTOR","SERVICE_ACCOUNT"]},"organization_id":{"type":"string","format":"string"},"position":{"type":"string","description":"Position"},"primary_email_address":{"type":"string","description":"Primary email address"}}}`)
|
||||||
|
AddPeopleToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["people"],"properties":{"people":{"type":"object","required":["id","organization_id","full_name","primary_email_address","additional_email_addresses","kind","created_at","updated_at"],"properties":{"additional_email_addresses":{"type":"array","description":"Additional email addresses","items":{"type":"string"}},"contract_end_date":{"description":"Contract end date","format":"date-time"},"contract_start_date":{"description":"Contract start date","format":"date-time"},"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"full_name":{"type":"string","description":"Full name"},"id":{"type":"string","format":"string"},"kind":{"type":"string","enum":["EMPLOYEE","CONTRACTOR","SERVICE_ACCOUNT"]},"organization_id":{"type":"string","format":"string"},"position":{"description":"Position"},"primary_email_address":{"type":"string","description":"Primary email address"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`)
|
||||||
AddVendorToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["organization_id","name"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"type":"string","description":"Vendor description"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}`)
|
AddVendorToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["organization_id","name"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"type":"string","description":"Vendor description"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}`)
|
||||||
AddVendorToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["vendor"],"properties":{"vendor":{"type":"object","required":["id","name","organization_id","created_at","updated_at"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"description":"Vendor description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`)
|
AddVendorToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["vendor"],"properties":{"vendor":{"type":"object","required":["id","name","organization_id","created_at","updated_at"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"description":"Vendor description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`)
|
||||||
GetPeopleToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"string"}}}`)
|
GetPeopleToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"string"}}}`)
|
||||||
@@ -26,6 +28,31 @@ var (
|
|||||||
UpdateVendorToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["vendor"],"properties":{"vendor":{"type":"object","required":["id","name","organization_id","created_at","updated_at"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"description":"Vendor description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`)
|
UpdateVendorToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["vendor"],"properties":{"vendor":{"type":"object","required":["id","name","organization_id","created_at","updated_at"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"description":"Vendor description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AddPeopleInput represents the schema
|
||||||
|
type AddPeopleInput struct {
|
||||||
|
// Additional email addresses
|
||||||
|
AdditionalEmailAddresses []string `json:"additional_email_addresses,omitempty"`
|
||||||
|
// Contract end date
|
||||||
|
ContractEndDate *time.Time `json:"contract_end_date,omitempty"`
|
||||||
|
// Contract start date
|
||||||
|
ContractStartDate *time.Time `json:"contract_start_date,omitempty"`
|
||||||
|
// Full name
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
// People kind
|
||||||
|
Kind coredata.PeopleKind `json:"kind"`
|
||||||
|
// Organization ID
|
||||||
|
OrganizationID gid.GID `json:"organization_id"`
|
||||||
|
// Position
|
||||||
|
Position *string `json:"position,omitempty"`
|
||||||
|
// Primary email address
|
||||||
|
PrimaryEmailAddress string `json:"primary_email_address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPeopleOutput represents the schema
|
||||||
|
type AddPeopleOutput struct {
|
||||||
|
People *People `json:"people"`
|
||||||
|
}
|
||||||
|
|
||||||
// AddVendorInput represents the schema
|
// AddVendorInput represents the schema
|
||||||
type AddVendorInput struct {
|
type AddVendorInput struct {
|
||||||
// Creation timestamp
|
// Creation timestamp
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package mcp_v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,27 +12,14 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/authz"
|
"go.probo.inc/probo/pkg/authz"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/mcp/mcputils"
|
||||||
"go.probo.inc/probo/pkg/server/api/mcp/v1/server"
|
"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, tenantID gid.TenantID) *probo.TenantService {
|
func (r *Resolver) ProboService(ctx context.Context, objectID gid.GID) *probo.TenantService {
|
||||||
validateTenantAccess(ctx, tenantID)
|
serverauth.RequireTenantAccess(ctx, objectID.TenantID())
|
||||||
return r.proboSvc.WithTenant(tenantID)
|
return r.proboSvc.WithTenant(objectID.TenantID())
|
||||||
}
|
|
||||||
|
|
||||||
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
|
|
||||||
mcpCtx := MCPContextFromContext(ctx)
|
|
||||||
if mcpCtx == nil {
|
|
||||||
panic(fmt.Errorf("authentication context not found"))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tid := range mcpCtx.TenantIDs {
|
|
||||||
if tid == tenantID {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
panic(fmt.Errorf("access denied: user does not have access to tenant %s", tenantID.String()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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, authSvc *auth.Service, authzSvc *authz.Service, cfg Config) *chi.Mux {
|
||||||
@@ -54,6 +40,10 @@ func NewMux(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service,
|
|||||||
|
|
||||||
mcpServer := server.New(resolver)
|
mcpServer := server.New(resolver)
|
||||||
|
|
||||||
|
// Add panic recovery middleware to handle panics in goroutines spawned by MCP SDK
|
||||||
|
mcpServer.AddReceivingMiddleware(mcputils.LoggingMiddleware(logger))
|
||||||
|
mcpServer.AddReceivingMiddleware(mcputils.RecoveryMiddleware(logger))
|
||||||
|
|
||||||
getServer := func(r *http.Request) *mcp.Server { return mcpServer }
|
getServer := func(r *http.Request) *mcp.Server { return mcpServer }
|
||||||
eventStore := mcp.NewMemoryEventStore(nil)
|
eventStore := mcp.NewMemoryEventStore(nil)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ package trust_v1
|
|||||||
|
|
||||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
// 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.
|
// 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.76
|
// Code generated by github.com/99designs/gqlgen version v0.17.83
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|||||||
120
pkg/server/auth/apikey.go
Normal file
120
pkg/server/auth/apikey.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// 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"})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ type ctxKey struct{ name string }
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
sessionContextKey = &ctxKey{name: "session"}
|
sessionContextKey = &ctxKey{name: "session"}
|
||||||
userContextKey = &ctxKey{name: "user"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func RequireAuth(
|
func RequireAuth(
|
||||||
@@ -78,7 +77,7 @@ func RequireAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
|
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
|
||||||
ctx = context.WithValue(ctx, userContextKey, authResult.User)
|
ctx = context.WithValue(ctx, UserContextKey, authResult.User)
|
||||||
|
|
||||||
next(w, r.WithContext(ctx))
|
next(w, r.WithContext(ctx))
|
||||||
}
|
}
|
||||||
@@ -88,8 +87,3 @@ func SessionFromContext(ctx context.Context) *coredata.Session {
|
|||||||
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
|
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserFromContext(ctx context.Context) *coredata.User {
|
|
||||||
user, _ := ctx.Value(userContextKey).(*coredata.User)
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user