Add tools and update authenticztion and RBAC

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-11-21 10:22:01 +01:00
parent deb656d95e
commit dfd924abeb
16 changed files with 15634 additions and 39441 deletions

View File

@@ -21,7 +21,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"time"
@@ -44,6 +43,7 @@ import (
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/saferedirect"
"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"
@@ -69,18 +69,10 @@ type (
}
ctxKey struct{ name string }
userTenantAccess struct {
tenantIDs []gid.TenantID
authErrors map[gid.TenantID]error
}
)
var (
sessionContextKey = &ctxKey{name: "session"}
userContextKey = &ctxKey{name: "user"}
userTenantContextKey = &ctxKey{name: "user_tenants"}
userAPIKeyContextKey = &ctxKey{name: "user_api_key"}
sessionContextKey = &ctxKey{name: "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 {
user, _ := ctx.Value(userContextKey).(*coredata.User)
return user
return serverauth.UserFromContext(ctx)
}
func UserAPIKeyFromContext(ctx context.Context) *coredata.UserAPIKey {
userAPIKey, _ := ctx.Value(userAPIKeyContextKey).(*coredata.UserAPIKey)
return userAPIKey
return serverauth.UserAPIKeyFromContext(ctx)
}
func NewMux(
@@ -377,7 +367,7 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
return func(w http.ResponseWriter, r *http.Request) {
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))
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, userContextKey, authResult.User)
ctx = context.WithValue(ctx, userTenantContextKey, &userTenantAccess{
tenantIDs: authResult.TenantIDs,
authErrors: authResult.AuthErrors,
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))
@@ -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 {
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 {
validateTenantAccess(ctx, tenantID)
serverauth.RequireTenantAccess(ctx, tenantID)
return proboSvc.WithTenant(tenantID)
}
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
validateTenantAccess(ctx, tenantID)
serverauth.RequireTenantAccess(ctx, tenantID)
return authzSvc.WithTenant(tenantID)
}
func GetTenantAuthService(ctx context.Context, authSvc *auth.Service, tenantID gid.TenantID) *auth.TenantAuthService {
validateTenantAccess(ctx, tenantID)
serverauth.RequireTenantAccess(ctx, 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) {
user := UserFromContext(ctx)
apiKey := UserAPIKeyFromContext(ctx)

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ package console_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.76
// Code generated by github.com/99designs/gqlgen version v0.17.83
import (
"context"
@@ -23,6 +23,7 @@ import (
"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/types"
serverauth "go.probo.inc/probo/pkg/server/auth"
"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
access, _ := ctx.Value(userTenantContextKey).(*userTenantAccess)
access := serverauth.UserTenantAccessFromContext(ctx)
if access != nil {
access.tenantIDs = append(access.tenantIDs, organization.ID.TenantID())
access.TenantIDs = append(access.TenantIDs, organization.ID.TenantID())
}
return &types.CreateOrganizationPayload{

View 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")
}

View File

@@ -16,12 +16,11 @@ package mcp_v1
import (
"net/http"
"strings"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
"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
@@ -44,71 +43,27 @@ func WithMCPAuth(
log.String("path", r.URL.Path),
)
// Extract API key from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
logger.WarnCtx(ctx, "MCP auth: missing Authorization header",
// 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)
return
}
// Expect "Bearer <token>" format
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
logger.WarnCtx(ctx, "MCP auth: invalid Authorization header format",
log.String("correlation_id", correlationID),
)
http.Error(w, "invalid authorization header", http.StatusUnauthorized)
return
}
user := serverauth.UserFromContext(authCtx)
userAPIKey := serverauth.UserAPIKeyFromContext(authCtx)
tenantAccess := serverauth.UserTenantAccessFromContext(authCtx)
apiKeyToken := parts[1]
// 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",
logger.InfoCtx(authCtx, "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(tenantIDs)),
log.Int("accessible_tenants", len(tenantAccess.TenantIDs)),
)
next.ServeHTTP(w, r.WithContext(ctx))
next.ServeHTTP(w, r.WithContext(authCtx))
})
}

View File

@@ -3,10 +3,14 @@
package mcp_v1
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/probo"
serverauth "go.probo.inc/probo/pkg/server/auth"
)
type Resolver struct {
@@ -15,3 +19,26 @@ type Resolver struct {
authzSvc *authz.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)
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)
}

View File

@@ -9,17 +9,23 @@ 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/page"
"go.probo.inc/probo/pkg/probo"
"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) {
mcpCtx := MCPContextFromContext(ctx)
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, mcpCtx.UserID)
user := serverauth.UserFromContext(ctx)
if user == nil {
return nil, types.ListOrganizationsOutput{}, fmt.Errorf("authentication required")
}
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil {
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
// List all vendors for the organization
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]{
Field: coredata.VendorOrderFieldCreatedAt,
@@ -69,7 +77,9 @@ 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) {
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(
ctx,
@@ -106,11 +116,14 @@ func (r *Resolver) AddVendorTool(ctx context.Context, req *mcp.CallToolRequest,
// UpdateVendorTool handles the updateVendor tool
// Update an existing vendor
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")
}
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]{
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) {
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)
if err != nil {
@@ -150,3 +165,30 @@ func (r *Resolver) GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest,
People: types.NewPeople(people),
}, 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
}

View File

@@ -16,6 +16,7 @@ type ResolverInterface interface {
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)
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.
@@ -95,4 +96,14 @@ func registerToolHandlers(server *mcp.Server, resolver ResolverInterface) {
},
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,
)
}

View File

@@ -352,6 +352,51 @@ components:
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:
- name: listOrganizations
description: List all organizations the user has access to
@@ -395,3 +440,10 @@ tools:
$ref: "#/components/schemas/GetPeopleInput"
outputSchema:
$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"

View File

@@ -15,10 +15,7 @@
package mcp_v1
import (
"context"
"time"
"go.probo.inc/probo/pkg/gid"
)
type (
@@ -27,28 +24,8 @@ type (
RequestTimeout time.Duration
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 {
return Config{
Version: "1.0.0",

View File

@@ -12,6 +12,8 @@ import (
// Tool input schemas
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"}}}`)
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"}}}`)
@@ -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"}}}}}`)
)
// 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
type AddVendorInput struct {
// Creation timestamp

View File

@@ -2,7 +2,6 @@ package mcp_v1
import (
"context"
"fmt"
"net/http"
"time"
@@ -13,27 +12,14 @@ import (
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/gid"
"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"
serverauth "go.probo.inc/probo/pkg/server/auth"
)
func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService {
validateTenantAccess(ctx, tenantID)
return r.proboSvc.WithTenant(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 (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 {
@@ -54,6 +40,10 @@ func NewMux(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service,
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 }
eventStore := mcp.NewMemoryEventStore(nil)

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ package trust_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.76
// Code generated by github.com/99designs/gqlgen version v0.17.83
import (
"context"

120
pkg/server/auth/apikey.go Normal file
View 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"})
}
}

View File

@@ -30,7 +30,6 @@ type ctxKey struct{ name string }
var (
sessionContextKey = &ctxKey{name: "session"}
userContextKey = &ctxKey{name: "user"}
)
func RequireAuth(
@@ -78,7 +77,7 @@ func RequireAuth(
}
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))
}
@@ -88,8 +87,3 @@ 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
}