diff --git a/pkg/resolvemcp/README.md b/pkg/resolvemcp/README.md index e093d57..7bb6429 100644 --- a/pkg/resolvemcp/README.md +++ b/pkg/resolvemcp/README.md @@ -119,6 +119,83 @@ Add middleware before the MCP routes. The handler itself has no auth layer. --- +## Security + +`resolvemcp` integrates with the `security` package to provide per-entity access control, row-level security, and column-level security — the same system used by `resolvespec` and `restheadspec`. + +### Wiring security hooks + +```go +import "github.com/bitechdev/ResolveSpec/pkg/security" + +securityList := security.NewSecurityList(mySecurityProvider) +resolvemcp.RegisterSecurityHooks(handler, securityList) +``` + +Call `RegisterSecurityHooks` **once**, after creating the handler and before registering models. It installs these controls automatically: + +| Hook | Effect | +|---|---| +| `BeforeHandle` | Enforces per-entity operation rules (see below) | +| `BeforeRead` | Loads RLS/CLS rules, then injects a user-scoped WHERE clause | +| `AfterRead` | Masks/hides columns per column-security rules; writes audit log | +| `BeforeUpdate` | Blocks update if `CanUpdate` is false | +| `BeforeDelete` | Blocks delete if `CanDelete` is false | + +### Per-entity operation rules + +Use `RegisterModelWithRules` instead of `RegisterModel` to set access rules at registration time: + +```go +import "github.com/bitechdev/ResolveSpec/pkg/modelregistry" + +// Read-only entity +handler.RegisterModelWithRules("public", "audit_logs", &AuditLog{}, modelregistry.ModelRules{ + CanRead: true, + CanCreate: false, + CanUpdate: false, + CanDelete: false, +}) + +// Public read, authenticated write +handler.RegisterModelWithRules("public", "products", &Product{}, modelregistry.ModelRules{ + CanPublicRead: true, + CanRead: true, + CanCreate: true, + CanUpdate: true, + CanDelete: false, +}) +``` + +To update rules for an already-registered model: + +```go +handler.SetModelRules("public", "users", modelregistry.ModelRules{ + CanRead: true, + CanCreate: true, + CanUpdate: true, + CanDelete: false, +}) +``` + +`RegisterModel` (no rules) registers with all-allowed defaults (`CanRead/Create/Update/Delete = true`). + +### ModelRules fields + +| Field | Default | Description | +|---|---|---| +| `CanPublicRead` | `false` | Allow unauthenticated reads | +| `CanPublicCreate` | `false` | Allow unauthenticated creates | +| `CanPublicUpdate` | `false` | Allow unauthenticated updates | +| `CanPublicDelete` | `false` | Allow unauthenticated deletes | +| `CanRead` | `true` | Allow authenticated reads | +| `CanCreate` | `true` | Allow authenticated creates | +| `CanUpdate` | `true` | Allow authenticated updates | +| `CanDelete` | `true` | Allow authenticated deletes | +| `SecurityDisabled` | `false` | Skip all security checks for this model | + +--- + ## MCP Tools ### Tool Naming @@ -204,6 +281,35 @@ Delete a record by primary key. **Irreversible.** { "success": true, "data": { ...deleted record... } } ``` +### Annotation Tool — `resolvespec_annotate` + +Store or retrieve freeform annotation records for any tool, model, or entity. Registered automatically on every handler. + +| Argument | Type | Description | +|---|---|---| +| `tool_name` | string (required) | Key to annotate — an MCP tool name (e.g. `read_public_users`), a model name (e.g. `public.users`), or any other identifier. | +| `annotations` | object | Annotation data to persist. Omit to retrieve existing annotations instead. | + +**Set annotations** (calls `resolvespec_set_annotation(tool_name, annotations)`): +```json +{ "tool_name": "read_public_users", "annotations": { "description": "Returns active users", "owner": "platform-team" } } +``` +**Response:** +```json +{ "success": true, "tool_name": "read_public_users", "action": "set" } +``` + +**Get annotations** (calls `resolvespec_get_annotation(tool_name)`): +```json +{ "tool_name": "read_public_users" } +``` +**Response:** +```json +{ "success": true, "tool_name": "read_public_users", "action": "get", "annotations": { ... } } +``` + +--- + ### Resource — `{schema}.{entity}` Each model is also registered as an MCP resource with URI `schema.entity` (or just `entity` when schema is empty). Reading the resource returns up to 100 records as `application/json`. diff --git a/pkg/resolvemcp/annotation.go b/pkg/resolvemcp/annotation.go new file mode 100644 index 0000000..af56fee --- /dev/null +++ b/pkg/resolvemcp/annotation.go @@ -0,0 +1,107 @@ +package resolvemcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" +) + +const annotationToolName = "resolvespec_annotate" + +// registerAnnotationTool adds the resolvespec_annotate tool to the MCP server. +// The tool lets models/entities store and retrieve freeform annotation records +// using the resolvespec_set_annotation / resolvespec_get_annotation database procedures. +func registerAnnotationTool(h *Handler) { + tool := mcp.NewTool(annotationToolName, + mcp.WithDescription( + "Store or retrieve annotations for any MCP tool, model, or entity.\n\n"+ + "To set annotations: provide both 'tool_name' and 'annotations'. "+ + "Calls resolvespec_set_annotation(tool_name, annotations) to persist the data.\n\n"+ + "To get annotations: provide only 'tool_name'. "+ + "Calls resolvespec_get_annotation(tool_name) and returns the stored annotations.\n\n"+ + "'tool_name' may be any identifier: an MCP tool name (e.g. 'read_public_users'), "+ + "a model/entity name (e.g. 'public.users'), or any other key.", + ), + mcp.WithString("tool_name", + mcp.Description("Name of the tool, model, or entity to annotate (e.g. 'read_public_users', 'public.users')."), + mcp.Required(), + ), + mcp.WithObject("annotations", + mcp.Description("Annotation data to store. Omit to retrieve existing annotations instead of setting them."), + ), + ) + + h.mcpServer.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + + toolName, ok := args["tool_name"].(string) + if !ok || toolName == "" { + return mcp.NewToolResultError("missing required argument: tool_name"), nil + } + + annotations, hasAnnotations := args["annotations"] + + if hasAnnotations && annotations != nil { + return executeSetAnnotation(ctx, h, toolName, annotations) + } + return executeGetAnnotation(ctx, h, toolName) + }) +} + +func executeSetAnnotation(ctx context.Context, h *Handler, toolName string, annotations interface{}) (*mcp.CallToolResult, error) { + jsonBytes, err := json.Marshal(annotations) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal annotations: %v", err)), nil + } + + _, err = h.db.Exec(ctx, "SELECT resolvespec_set_annotation($1, $2)", toolName, string(jsonBytes)) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to set annotation: %v", err)), nil + } + + return marshalResult(map[string]interface{}{ + "success": true, + "tool_name": toolName, + "action": "set", + }) +} + +func executeGetAnnotation(ctx context.Context, h *Handler, toolName string) (*mcp.CallToolResult, error) { + var rows []map[string]interface{} + err := h.db.Query(ctx, &rows, "SELECT resolvespec_get_annotation($1)", toolName) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get annotation: %v", err)), nil + } + + var annotations interface{} + if len(rows) > 0 { + // The procedure returns a single value; extract the first column of the first row. + for _, v := range rows[0] { + annotations = v + break + } + } + + // If the value is a []byte or string containing JSON, decode it so it round-trips cleanly. + switch v := annotations.(type) { + case []byte: + var decoded interface{} + if json.Unmarshal(v, &decoded) == nil { + annotations = decoded + } + case string: + var decoded interface{} + if json.Unmarshal([]byte(v), &decoded) == nil { + annotations = decoded + } + } + + return marshalResult(map[string]interface{}{ + "success": true, + "tool_name": toolName, + "action": "get", + "annotations": annotations, + }) +} diff --git a/pkg/resolvemcp/handler.go b/pkg/resolvemcp/handler.go index 93f4758..cf52414 100644 --- a/pkg/resolvemcp/handler.go +++ b/pkg/resolvemcp/handler.go @@ -14,6 +14,7 @@ import ( "github.com/bitechdev/ResolveSpec/pkg/common" "github.com/bitechdev/ResolveSpec/pkg/logger" + "github.com/bitechdev/ResolveSpec/pkg/modelregistry" "github.com/bitechdev/ResolveSpec/pkg/reflection" ) @@ -30,7 +31,7 @@ type Handler struct { // NewHandler creates a Handler with the given database, model registry, and config. func NewHandler(db common.Database, registry common.ModelRegistry, cfg Config) *Handler { - return &Handler{ + h := &Handler{ db: db, registry: registry, hooks: NewHookRegistry(), @@ -39,6 +40,8 @@ func NewHandler(db common.Database, registry common.ModelRegistry, cfg Config) * name: "resolvemcp", version: "1.0.0", } + registerAnnotationTool(h) + return h } // Hooks returns the hook registry. @@ -123,6 +126,32 @@ func (h *Handler) RegisterModel(schema, entity string, model interface{}) error return nil } +// RegisterModelWithRules registers a model and sets per-entity operation rules +// (CanRead, CanCreate, CanUpdate, CanDelete, CanPublic*, SecurityDisabled). +// Requires RegisterSecurityHooks to have been called for the rules to be enforced. +func (h *Handler) RegisterModelWithRules(schema, entity string, model interface{}, rules modelregistry.ModelRules) error { + reg, ok := h.registry.(*modelregistry.DefaultModelRegistry) + if !ok { + return fmt.Errorf("resolvemcp: registry does not support model rules (use NewHandlerWithGORM/Bun/DB)") + } + fullName := buildModelName(schema, entity) + if err := reg.RegisterModelWithRules(fullName, model, rules); err != nil { + return err + } + registerModelTools(h, schema, entity, model) + return nil +} + +// SetModelRules updates the operation rules for an already-registered model. +// Requires RegisterSecurityHooks to have been called for the rules to be enforced. +func (h *Handler) SetModelRules(schema, entity string, rules modelregistry.ModelRules) error { + reg, ok := h.registry.(*modelregistry.DefaultModelRegistry) + if !ok { + return fmt.Errorf("resolvemcp: registry does not support model rules (use NewHandlerWithGORM/Bun/DB)") + } + return reg.SetModelRules(buildModelName(schema, entity), rules) +} + // buildModelName builds the registry key for a model (same format as resolvespec). func buildModelName(schema, entity string) string { if schema == "" { diff --git a/pkg/resolvemcp/security_hooks.go b/pkg/resolvemcp/security_hooks.go new file mode 100644 index 0000000..843e6b5 --- /dev/null +++ b/pkg/resolvemcp/security_hooks.go @@ -0,0 +1,115 @@ +package resolvemcp + +import ( + "context" + "net/http" + + "github.com/bitechdev/ResolveSpec/pkg/common" + "github.com/bitechdev/ResolveSpec/pkg/logger" + "github.com/bitechdev/ResolveSpec/pkg/security" +) + +// RegisterSecurityHooks wires the security package's access-control layer into the +// resolvemcp handler. Call it once after creating the handler, before registering models. +// +// The following controls are applied: +// - Per-entity operation rules (CanRead, CanCreate, CanUpdate, CanDelete, CanPublic*) +// stored via RegisterModelWithRules / SetModelRules. +// - Row-level security: WHERE clause injected per user from the SecurityList provider. +// - Column-level security: sensitive columns masked/hidden in read results. +// - Audit logging after each read. +func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList) { + // BeforeHandle: enforce model-level operation rules (auth check). + handler.Hooks().Register(BeforeHandle, func(hookCtx *HookContext) error { + if err := security.CheckModelAuthAllowed(newSecurityContext(hookCtx), hookCtx.Operation); err != nil { + hookCtx.Abort = true + hookCtx.AbortMessage = err.Error() + hookCtx.AbortCode = http.StatusUnauthorized + return err + } + return nil + }) + + // BeforeRead (1st): load RLS + CLS rules from the provider into SecurityList. + handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { + return security.LoadSecurityRules(newSecurityContext(hookCtx), securityList) + }) + + // BeforeRead (2nd): apply row-level security — injects a WHERE clause into the query. + // resolvemcp has no separate BeforeScan hook; the query is available in BeforeRead. + handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error { + return security.ApplyRowSecurity(newSecurityContext(hookCtx), securityList) + }) + + // AfterRead (1st): apply column-level security — mask/hide columns in the result. + handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { + return security.ApplyColumnSecurity(newSecurityContext(hookCtx), securityList) + }) + + // AfterRead (2nd): audit log. + handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error { + return security.LogDataAccess(newSecurityContext(hookCtx)) + }) + + // BeforeUpdate: enforce CanUpdate rule. + handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error { + return security.CheckModelUpdateAllowed(newSecurityContext(hookCtx)) + }) + + // BeforeDelete: enforce CanDelete rule. + handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error { + return security.CheckModelDeleteAllowed(newSecurityContext(hookCtx)) + }) + + logger.Info("Security hooks registered for resolvemcp handler") +} + +// -------------------------------------------------------------------------- +// securityContext — adapts resolvemcp.HookContext to security.SecurityContext +// -------------------------------------------------------------------------- + +type securityContext struct { + ctx *HookContext +} + +func newSecurityContext(ctx *HookContext) security.SecurityContext { + return &securityContext{ctx: ctx} +} + +func (s *securityContext) GetContext() context.Context { + return s.ctx.Context +} + +func (s *securityContext) GetUserID() (int, bool) { + return security.GetUserID(s.ctx.Context) +} + +func (s *securityContext) GetSchema() string { + return s.ctx.Schema +} + +func (s *securityContext) GetEntity() string { + return s.ctx.Entity +} + +func (s *securityContext) GetModel() interface{} { + return s.ctx.Model +} + +func (s *securityContext) GetQuery() interface{} { + return s.ctx.Query +} + +func (s *securityContext) SetQuery(query interface{}) { + if q, ok := query.(common.SelectQuery); ok { + s.ctx.Query = q + } +} + +func (s *securityContext) GetResult() interface{} { + return s.ctx.Result +} + +func (s *securityContext) SetResult(result interface{}) { + s.ctx.Result = result +}