From b571b074db54142174f15233881c36d47c7299ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:51:46 +0200 Subject: [PATCH] Add access review MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MCP tool definitions and resolvers for access review campaigns, sources, entries, decisions, and flags. Wire accessreview.Service into the MCP Resolver. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/server/api/mcp/v1/resolver.go | 8 +- pkg/server/api/mcp/v1/schema.resolvers.go | 570 ++++++++++- pkg/server/api/mcp/v1/specification.yaml | 987 +++++++++++++++++++ pkg/server/api/mcp/v1/types/access_review.go | 161 +++ pkg/server/api/mcp/v1/v1_handler.go | 10 +- 5 files changed, 1691 insertions(+), 45 deletions(-) create mode 100644 pkg/server/api/mcp/v1/types/access_review.go diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index 0e7552ddf..aae168ef4 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -22,6 +22,7 @@ import ( "fmt" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/probo" @@ -30,9 +31,10 @@ import ( ) type Resolver struct { - proboSvc *probo.Service - iamSvc *iam.Service - logger *log.Logger + proboSvc *probo.Service + iamSvc *iam.Service + accessReview *accessreview.Service + logger *log.Logger } func markdownToProseMirrorJSON(markdown string) (string, error) { diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 9a37d49db..ff2e9f7f5 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -1,17 +1,3 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// 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 mcp_v1 // This file will be automatically regenerated based on the schema, any resolver implementations @@ -26,6 +12,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" + "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" @@ -1404,9 +1391,9 @@ func (r *Resolver) AddAuditTool(ctx context.Context, req *mcp.CallToolRequest, i func (r *Resolver) UpdateAuditTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateAuditInput) (*mcp.CallToolResult, types.UpdateAuditOutput, error) { r.MustAuthorize(ctx, input.ID, probo.ActionAuditUpdate) - prb := r.ProboService(ctx, input.ID) + svc := r.ProboService(ctx, input.ID) - audit, err := prb.Audits.Update( + audit, err := svc.Audits.Update( ctx, &probo.UpdateAuditRequest{ ID: input.ID, @@ -1423,7 +1410,7 @@ func (r *Resolver) UpdateAuditTool(ctx context.Context, req *mcp.CallToolRequest var report *coredata.Report if audit.ReportID != nil { - report, err = prb.Reports.Get(ctx, *audit.ReportID) + report, err = svc.Reports.Get(ctx, *audit.ReportID) if err != nil { return nil, types.UpdateAuditOutput{}, fmt.Errorf("cannot get audit report: %w", err) } @@ -3180,6 +3167,528 @@ func (r *Resolver) ListFindingAuditsTool(ctx context.Context, req *mcp.CallToolR return nil, types.NewListFindingAuditsOutput(auditPage), nil } +// ListAccessReviewCampaignsTool handles the listAccessReviewCampaigns tool +// List access review campaigns for an organization +func (r *Resolver) ListAccessReviewCampaignsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessReviewCampaignsInput) (*mcp.CallToolResult, types.ListAccessReviewCampaignsOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionAccessReviewCampaignList) + + scope := coredata.NewScopeFromObjectID(input.OrganizationID) + + pageOrderBy := page.OrderBy[coredata.AccessReviewCampaignOrderField]{ + Field: coredata.AccessReviewCampaignOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if input.OrderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessReviewCampaignOrderField]{ + Field: input.OrderBy.Field, + Direction: input.OrderBy.Direction, + } + } + + cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) + + p, err := r.accessReview.Campaigns(scope).ListForOrganizationID(ctx, input.OrganizationID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list access review campaigns: %w", err)) + } + + return nil, types.NewListAccessReviewCampaignsOutput(p), nil +} + +// ListAccessEntriesTool handles the listAccessEntries tool +// List access entries for a campaign with optional filters +func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessEntriesInput) (*mcp.CallToolResult, types.ListAccessEntriesOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessEntryList) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + pageOrderBy := page.OrderBy[coredata.AccessEntryOrderField]{ + Field: coredata.AccessEntryOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if input.OrderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessEntryOrderField]{ + Field: input.OrderBy.Field, + Direction: input.OrderBy.Direction, + } + } + + cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) + + var filter *coredata.AccessEntryFilter + if input.Filter != nil { + filter = &coredata.AccessEntryFilter{ + Decision: input.Filter.Decision, + Flag: input.Filter.Flag, + IncrementalTag: input.Filter.IncrementalTag, + IsAdmin: input.Filter.IsAdmin, + AuthMethod: input.Filter.AuthMethod, + } + } + + var p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField] + + if input.AccessSourceID != nil { + var err error + p, err = r.accessReview.Entries(scope).ListForCampaignIDAndSourceID( + ctx, + input.CampaignID, + *input.AccessSourceID, + cursor, + filter, + ) + if err != nil { + panic(fmt.Errorf("cannot list access entries: %w", err)) + } + } else { + var err error + p, err = r.accessReview.Entries(scope).ListForCampaignID(ctx, input.CampaignID, cursor, filter) + if err != nil { + panic(fmt.Errorf("cannot list access entries: %w", err)) + } + } + + return nil, types.NewListAccessEntriesOutput(p), nil +} + +// GetAccessReviewCampaignStatisticsTool handles the getAccessReviewCampaignStatistics tool +// Get statistics for an access review campaign +func (r *Resolver) GetAccessReviewCampaignStatisticsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAccessReviewCampaignStatisticsInput) (*mcp.CallToolResult, types.GetAccessReviewCampaignStatisticsOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignGet) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + stats, err := r.accessReview.Entries(scope).Statistics(ctx, input.CampaignID) + if err != nil { + panic(fmt.Errorf("cannot get campaign statistics: %w", err)) + } + + return nil, types.GetAccessReviewCampaignStatisticsOutput{ + Statistics: types.NewAccessEntryStatistics(stats), + }, nil +} + +// RecordAccessEntryDecisionTool handles the recordAccessEntryDecision tool +// Record a decision on an access entry +func (r *Resolver) RecordAccessEntryDecisionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RecordAccessEntryDecisionInput) (*mcp.CallToolResult, types.RecordAccessEntryDecisionOutput, error) { + r.MustAuthorize(ctx, input.AccessEntryID, probo.ActionAccessEntryDecide) + + scope := coredata.NewScopeFromObjectID(input.AccessEntryID) + + identity := authn.IdentityFromContext(ctx) + if identity == nil { + return nil, types.RecordAccessEntryDecisionOutput{}, fmt.Errorf("no identity in context") + } + + decisionReq := accessreview.RecordAccessEntryDecisionRequest{ + EntryID: input.AccessEntryID, + Decision: input.Decision, + DecisionNote: input.DecisionNote, + } + + organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessEntryID) + if err == nil { + profile, err := r.iamSvc.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID) + if err == nil { + decisionReq.DecidedByID = &profile.ID + } + } + + entry, err := r.accessReview.Entries(scope).RecordDecision(ctx, decisionReq) + if err != nil { + return nil, types.RecordAccessEntryDecisionOutput{}, fmt.Errorf("cannot record decision: %w", err) + } + + return nil, types.RecordAccessEntryDecisionOutput{ + AccessEntry: types.NewAccessEntry(entry), + }, nil +} + +// RecordAccessEntryDecisionsTool handles the recordAccessEntryDecisions tool +// Record decisions on multiple access entries in a single batch +func (r *Resolver) RecordAccessEntryDecisionsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RecordAccessEntryDecisionsInput) (*mcp.CallToolResult, types.RecordAccessEntryDecisionsOutput, error) { + if len(input.Decisions) == 0 { + return nil, types.RecordAccessEntryDecisionsOutput{ + AccessEntries: []*types.AccessEntry{}, + }, nil + } + + const maxBatchSize = 100 + if len(input.Decisions) > maxBatchSize { + return nil, types.RecordAccessEntryDecisionsOutput{}, fmt.Errorf("cannot record decisions: batch size %d exceeds maximum of %d", len(input.Decisions), maxBatchSize) + } + + // Authorize each entry individually to prevent cross-org bypass. + for _, d := range input.Decisions { + r.MustAuthorize(ctx, d.AccessEntryID, probo.ActionAccessEntryDecide) + } + + scope := coredata.NewScopeFromObjectID(input.Decisions[0].AccessEntryID) + + identity := authn.IdentityFromContext(ctx) + if identity == nil { + return nil, types.RecordAccessEntryDecisionsOutput{}, fmt.Errorf("no identity in context") + } + + // Cache profile lookups per organization so we resolve the correct + // decidedByID for each entry even when a batch spans multiple orgs. + profileCache := make(map[gid.GID]*gid.GID) + + decisions := make([]accessreview.RecordAccessEntryDecisionRequest, len(input.Decisions)) + for i, d := range input.Decisions { + var decidedByID *gid.GID + organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessEntryID) + if err == nil { + if cached, ok := profileCache[organizationID]; ok { + decidedByID = cached + } else { + profile, err := r.iamSvc.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID) + if err == nil { + decidedByID = &profile.ID + } + profileCache[organizationID] = decidedByID + } + } + + decisions[i] = accessreview.RecordAccessEntryDecisionRequest{ + EntryID: d.AccessEntryID, + Decision: d.Decision, + DecisionNote: d.DecisionNote, + DecidedByID: decidedByID, + } + } + + entries, err := r.accessReview.Entries(scope).RecordDecisions(ctx, decisions) + if err != nil { + return nil, types.RecordAccessEntryDecisionsOutput{}, fmt.Errorf("cannot record decisions: %w", err) + } + + accessEntries := make([]*types.AccessEntry, len(entries)) + for i, e := range entries { + accessEntries[i] = types.NewAccessEntry(e) + } + + return nil, types.RecordAccessEntryDecisionsOutput{ + AccessEntries: accessEntries, + }, nil +} + +// CloseAccessReviewCampaignTool handles the closeAccessReviewCampaign tool +// Close an access review campaign +func (r *Resolver) CloseAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CloseAccessReviewCampaignInput) (*mcp.CallToolResult, types.CloseAccessReviewCampaignOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignClose) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + campaign, err := r.accessReview.Campaigns(scope).Close(ctx, input.CampaignID) + if err != nil { + return nil, types.CloseAccessReviewCampaignOutput{}, fmt.Errorf("cannot close campaign: %w", err) + } + + return nil, types.CloseAccessReviewCampaignOutput{ + Campaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// ListAccessSourcesTool handles the listAccessSources tool +// List access sources for an organization +func (r *Resolver) ListAccessSourcesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessSourcesInput) (*mcp.CallToolResult, types.ListAccessSourcesOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionAccessSourceList) + + scope := coredata.NewScopeFromObjectID(input.OrganizationID) + + pageOrderBy := page.OrderBy[coredata.AccessSourceOrderField]{ + Field: coredata.AccessSourceOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if input.OrderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessSourceOrderField]{ + Field: input.OrderBy.Field, + Direction: input.OrderBy.Direction, + } + } + + cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) + + p, err := r.accessReview.Sources(scope).ListForOrganizationID(ctx, input.OrganizationID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list access sources: %w", err)) + } + + return nil, types.NewListAccessSourcesOutput(p), nil +} + +// CreateAccessSourceTool handles the createAccessSource tool +// Create a new access source for an organization +func (r *Resolver) CreateAccessSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateAccessSourceInput) (*mcp.CallToolResult, types.CreateAccessSourceOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionAccessSourceCreate) + + scope := coredata.NewScopeFromObjectID(input.OrganizationID) + + source, err := r.accessReview.Sources(scope).Create(ctx, accessreview.CreateAccessSourceRequest{ + OrganizationID: input.OrganizationID, + ConnectorID: input.ConnectorID, + Name: input.Name, + Category: coredata.AccessSourceCategorySaaS, + CsvData: input.CsvData, + }) + if err != nil { + return nil, types.CreateAccessSourceOutput{}, fmt.Errorf("cannot create access source: %w", err) + } + + return nil, types.CreateAccessSourceOutput{ + AccessSource: types.NewAccessSource(source), + }, nil +} + +// UpdateAccessSourceTool handles the updateAccessSource tool +// Update an existing access source +func (r *Resolver) UpdateAccessSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateAccessSourceInput) (*mcp.CallToolResult, types.UpdateAccessSourceOutput, error) { + r.MustAuthorize(ctx, input.AccessSourceID, probo.ActionAccessSourceUpdate) + + scope := coredata.NewScopeFromObjectID(input.AccessSourceID) + + updateReq := accessreview.UpdateAccessSourceRequest{ + AccessSourceID: input.AccessSourceID, + Name: input.Name, + } + + if rawConnectorID := UnwrapOmittable(input.ConnectorID); rawConnectorID != nil { + if *rawConnectorID != nil { + id, err := gid.ParseGID(**rawConnectorID) + if err != nil { + return nil, types.UpdateAccessSourceOutput{}, fmt.Errorf("cannot parse connector_id: %w", err) + } + idPtr := &id + updateReq.ConnectorID = &idPtr + } else { + var nilGID *gid.GID + updateReq.ConnectorID = &nilGID + } + } + + if rawCsvData := UnwrapOmittable(input.CsvData); rawCsvData != nil { + updateReq.CsvData = rawCsvData + } + + source, err := r.accessReview.Sources(scope).Update(ctx, updateReq) + if err != nil { + return nil, types.UpdateAccessSourceOutput{}, fmt.Errorf("cannot update access source: %w", err) + } + + return nil, types.UpdateAccessSourceOutput{ + AccessSource: types.NewAccessSource(source), + }, nil +} + +// DeleteAccessSourceTool handles the deleteAccessSource tool +// Delete an access source +func (r *Resolver) DeleteAccessSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteAccessSourceInput) (*mcp.CallToolResult, types.DeleteAccessSourceOutput, error) { + r.MustAuthorize(ctx, input.AccessSourceID, probo.ActionAccessSourceDelete) + + scope := coredata.NewScopeFromObjectID(input.AccessSourceID) + + if err := r.accessReview.Sources(scope).Delete(ctx, input.AccessSourceID); err != nil { + return nil, types.DeleteAccessSourceOutput{}, fmt.Errorf("cannot delete access source: %w", err) + } + + return nil, types.DeleteAccessSourceOutput{ + DeletedAccessSourceID: input.AccessSourceID, + }, nil +} + +// CreateAccessReviewCampaignTool handles the createAccessReviewCampaign tool +// Create a new access review campaign for an organization +func (r *Resolver) CreateAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateAccessReviewCampaignInput) (*mcp.CallToolResult, types.CreateAccessReviewCampaignOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionAccessReviewCampaignCreate) + + scope := coredata.NewScopeFromObjectID(input.OrganizationID) + + var description string + if input.Description != nil { + description = *input.Description + } + + campaign, err := r.accessReview.Campaigns(scope).Create(ctx, accessreview.CreateAccessReviewCampaignRequest{ + OrganizationID: input.OrganizationID, + Name: input.Name, + Description: description, + FrameworkControls: input.FrameworkControls, + AccessSourceIDs: input.AccessSourceIds, + }) + if err != nil { + return nil, types.CreateAccessReviewCampaignOutput{}, fmt.Errorf("cannot create access review campaign: %w", err) + } + + return nil, types.CreateAccessReviewCampaignOutput{ + Campaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// UpdateAccessReviewCampaignTool handles the updateAccessReviewCampaign tool +// Update an existing access review campaign +func (r *Resolver) UpdateAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateAccessReviewCampaignInput) (*mcp.CallToolResult, types.UpdateAccessReviewCampaignOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignUpdate) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + updateReq := accessreview.UpdateAccessReviewCampaignRequest{ + CampaignID: input.CampaignID, + Name: input.Name, + Description: input.Description, + } + + if rawControls := UnwrapOmittable(input.FrameworkControls); rawControls != nil { + if *rawControls != nil { + controls := make([]string, 0, len(**rawControls)) + for _, v := range **rawControls { + if s, ok := v.(string); ok { + controls = append(controls, s) + } + } + updateReq.FrameworkControls = &controls + } else { + empty := []string{} + updateReq.FrameworkControls = &empty + } + } + + campaign, err := r.accessReview.Campaigns(scope).Update(ctx, updateReq) + if err != nil { + return nil, types.UpdateAccessReviewCampaignOutput{}, fmt.Errorf("cannot update access review campaign: %w", err) + } + + return nil, types.UpdateAccessReviewCampaignOutput{ + Campaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// DeleteAccessReviewCampaignTool handles the deleteAccessReviewCampaign tool +// Delete an access review campaign +func (r *Resolver) DeleteAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteAccessReviewCampaignInput) (*mcp.CallToolResult, types.DeleteAccessReviewCampaignOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignDelete) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + if err := r.accessReview.Campaigns(scope).Delete(ctx, input.CampaignID); err != nil { + return nil, types.DeleteAccessReviewCampaignOutput{}, fmt.Errorf("cannot delete access review campaign: %w", err) + } + + return nil, types.DeleteAccessReviewCampaignOutput{ + DeletedCampaignID: input.CampaignID, + }, nil +} + +// StartAccessReviewCampaignTool handles the startAccessReviewCampaign tool +// Start an access review campaign +func (r *Resolver) StartAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.StartAccessReviewCampaignInput) (*mcp.CallToolResult, types.StartAccessReviewCampaignOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignStart) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + campaign, err := r.accessReview.Campaigns(scope).Start(ctx, input.CampaignID) + if err != nil { + return nil, types.StartAccessReviewCampaignOutput{}, fmt.Errorf("cannot start access review campaign: %w", err) + } + + return nil, types.StartAccessReviewCampaignOutput{ + Campaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// CancelAccessReviewCampaignTool handles the cancelAccessReviewCampaign tool +// Cancel an in-progress access review campaign +func (r *Resolver) CancelAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CancelAccessReviewCampaignInput) (*mcp.CallToolResult, types.CancelAccessReviewCampaignOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignCancel) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + campaign, err := r.accessReview.Campaigns(scope).Cancel(ctx, input.CampaignID) + if err != nil { + return nil, types.CancelAccessReviewCampaignOutput{}, fmt.Errorf("cannot cancel access review campaign: %w", err) + } + + return nil, types.CancelAccessReviewCampaignOutput{ + Campaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// AddAccessReviewCampaignScopeSourceTool handles the addAccessReviewCampaignScopeSource tool +// Add an access source to an access review campaign's scope +func (r *Resolver) AddAccessReviewCampaignScopeSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddAccessReviewCampaignScopeSourceInput) (*mcp.CallToolResult, types.AddAccessReviewCampaignScopeSourceOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignAddScopeSource) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + campaign, err := r.accessReview.Campaigns(scope).AddScopeSource(ctx, accessreview.AddCampaignScopeSourceRequest{ + CampaignID: input.CampaignID, + AccessSourceID: input.AccessSourceID, + }) + if err != nil { + return nil, types.AddAccessReviewCampaignScopeSourceOutput{}, fmt.Errorf("cannot add scope source to access review campaign: %w", err) + } + + return nil, types.AddAccessReviewCampaignScopeSourceOutput{ + Campaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// RemoveAccessReviewCampaignScopeSourceTool handles the removeAccessReviewCampaignScopeSource tool +// Remove an access source from an access review campaign's scope +func (r *Resolver) RemoveAccessReviewCampaignScopeSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RemoveAccessReviewCampaignScopeSourceInput) (*mcp.CallToolResult, types.RemoveAccessReviewCampaignScopeSourceOutput, error) { + r.MustAuthorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignRemoveScopeSource) + + scope := coredata.NewScopeFromObjectID(input.CampaignID) + + campaign, err := r.accessReview.Campaigns(scope).RemoveScopeSource(ctx, accessreview.RemoveCampaignScopeSourceRequest{ + CampaignID: input.CampaignID, + AccessSourceID: input.AccessSourceID, + }) + if err != nil { + return nil, types.RemoveAccessReviewCampaignScopeSourceOutput{}, fmt.Errorf("cannot remove scope source from access review campaign: %w", err) + } + + return nil, types.RemoveAccessReviewCampaignScopeSourceOutput{ + Campaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// FlagAccessEntryTool handles the flagAccessEntry tool +// Flag an access entry during review +func (r *Resolver) FlagAccessEntryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.FlagAccessEntryInput) (*mcp.CallToolResult, types.FlagAccessEntryOutput, error) { + r.MustAuthorize(ctx, input.AccessEntryID, probo.ActionAccessEntryFlag) + + scope := coredata.NewScopeFromObjectID(input.AccessEntryID) + + entry, err := r.accessReview.Entries(scope).FlagEntry(ctx, accessreview.FlagAccessEntryRequest{ + EntryID: input.AccessEntryID, + Flags: input.Flags, + FlagReasons: input.FlagReasons, + }) + if err != nil { + return nil, types.FlagAccessEntryOutput{}, fmt.Errorf("cannot flag access entry: %w", err) + } + + return nil, types.FlagAccessEntryOutput{ + AccessEntry: types.NewAccessEntry(entry), + }, nil +} + +func (r *Resolver) GetAuditReportUrlTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAuditReportUrlInput) (*mcp.CallToolResult, types.GetAuditReportUrlOutput, error) { + r.MustAuthorize(ctx, input.ID, probo.ActionReportGetReportUrl) + + prb := r.ProboService(ctx, input.ID) + + url, err := prb.Audits.GenerateReportURL(ctx, input.ID, 15*time.Minute) + if err != nil { + return nil, types.GetAuditReportUrlOutput{}, fmt.Errorf("cannot generate audit report URL: %w", err) + } + + return nil, types.GetAuditReportUrlOutput{ + URL: *url, + }, nil +} + func (r *Resolver) ArchiveDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ArchiveDocumentInput) (*mcp.CallToolResult, types.ArchiveDocumentOutput, error) { r.MustAuthorize(ctx, input.ID, probo.ActionDocumentArchive) @@ -3250,18 +3759,16 @@ func (r *Resolver) UpdateOrganizationContextTool(ctx context.Context, req *mcp.C }, nil } -func (r *Resolver) GetAuditReportUrlTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAuditReportUrlInput) (*mcp.CallToolResult, types.GetAuditReportUrlOutput, error) { - r.MustAuthorize(ctx, input.ID, probo.ActionReportGetReportUrl) +func (r *Resolver) GetAuditLogEntryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAuditLogEntryInput) (*mcp.CallToolResult, types.GetAuditLogEntryOutput, error) { + r.MustAuthorize(ctx, input.ID, iam.ActionAuditLogEntryGet) - prb := r.ProboService(ctx, input.ID) - - url, err := prb.Audits.GenerateReportURL(ctx, input.ID, 15*time.Minute) + entry, err := r.iamSvc.OrganizationService.GetAuditLogEntry(ctx, input.ID) if err != nil { - return nil, types.GetAuditReportUrlOutput{}, fmt.Errorf("cannot generate audit report URL: %w", err) + panic(fmt.Errorf("cannot get audit log entry: %w", err)) } - return nil, types.GetAuditReportUrlOutput{ - URL: *url, + return nil, types.GetAuditLogEntryOutput{ + AuditLogEntry: types.NewAuditLogEntry(entry), }, nil } @@ -3299,19 +3806,6 @@ func (r *Resolver) ListAuditLogEntriesTool(ctx context.Context, req *mcp.CallToo return nil, types.NewListAuditLogEntriesOutput(p), nil } -func (r *Resolver) GetAuditLogEntryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAuditLogEntryInput) (*mcp.CallToolResult, types.GetAuditLogEntryOutput, error) { - r.MustAuthorize(ctx, input.ID, iam.ActionAuditLogEntryGet) - - entry, err := r.iamSvc.OrganizationService.GetAuditLogEntry(ctx, input.ID) - if err != nil { - panic(fmt.Errorf("cannot get audit log entry: %w", err)) - } - - return nil, types.GetAuditLogEntryOutput{ - AuditLogEntry: types.NewAuditLogEntry(entry), - }, nil -} - func (r *Resolver) RequestDocumentVersionApprovalTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RequestDocumentVersionApprovalInput) (*mcp.CallToolResult, types.RequestDocumentVersionApprovalOutput, error) { r.MustAuthorize(ctx, input.DocumentID, probo.ActionDocumentVersionRequestApproval) diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index a3b25c49d..d7ea57f36 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -6412,6 +6412,843 @@ components: $ref: "#/components/schemas/GID" description: Deleted applicability statement ID + AccessReviewCampaignStatus: + type: string + enum: + - DRAFT + - IN_PROGRESS + - PENDING_ACTIONS + - FAILED + - COMPLETED + - CANCELLED + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatus + + AccessEntryDecision: + type: string + enum: + - PENDING + - APPROVED + - REVOKE + - DEFER + - ESCALATE + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryDecision + + AccessEntryFlag: + type: string + enum: + - NONE + - ORPHANED + - INACTIVE + - EXCESSIVE + - ROLE_MISMATCH + - NEW + - DORMANT + - TERMINATED_USER + - CONTRACTOR_EXPIRED + - SOD_CONFLICT + - PRIVILEGED_ACCESS + - ROLE_CREEP + - NO_BUSINESS_JUSTIFICATION + - OUT_OF_DEPARTMENT + - SHARED_ACCOUNT + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryFlag + + AccessEntryIncrementalTag: + type: string + enum: + - NEW + - REMOVED + - UNCHANGED + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTag + + AccessEntryAuthMethod: + type: string + enum: + - SSO + - PASSWORD + - API_KEY + - SERVICE_ACCOUNT + - UNKNOWN + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethod + + AccessEntryAccountType: + type: string + enum: + - USER + - SERVICE_ACCOUNT + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryAccountType + + MFAStatus: + type: string + enum: + - ENABLED + - DISABLED + - UNKNOWN + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.MFAStatus + + AccessReviewCampaignOrderField: + type: string + enum: + - CREATED_AT + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewCampaignOrderField + + AccessReviewCampaignOrderBy: + type: object + required: + - field + - direction + properties: + field: + $ref: "#/components/schemas/AccessReviewCampaignOrderField" + description: Order field + direction: + $ref: "#/components/schemas/OrderDirection" + description: Order direction + + AccessEntryOrderField: + type: string + enum: + - CREATED_AT + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryOrderField + + AccessEntryOrderBy: + type: object + required: + - field + - direction + properties: + field: + $ref: "#/components/schemas/AccessEntryOrderField" + description: Order field + direction: + $ref: "#/components/schemas/OrderDirection" + description: Order direction + + AccessReviewCampaign: + type: object + required: + - id + - organization_id + - name + - status + - created_at + - updated_at + properties: + id: + $ref: "#/components/schemas/GID" + description: Campaign ID + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + name: + type: string + description: Campaign name + description: + type: string + description: Campaign description + status: + $ref: "#/components/schemas/AccessReviewCampaignStatus" + description: Campaign status + started_at: + type: + - string + - "null" + format: date-time + description: Campaign start time + completed_at: + type: + - string + - "null" + format: date-time + description: Campaign completion time + framework_controls: + type: array + items: + type: string + description: Framework controls + created_at: + type: string + format: date-time + description: Creation timestamp + updated_at: + type: string + format: date-time + description: Update timestamp + + AccessEntry: + type: object + required: + - id + - campaign_id + - access_source_id + - email + - full_name + - role + - job_title + - is_admin + - mfa_status + - auth_method + - account_type + - external_id + - incremental_tag + - flags + - decision + - created_at + - updated_at + properties: + id: + $ref: "#/components/schemas/GID" + description: Entry ID + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + access_source_id: + $ref: "#/components/schemas/GID" + description: Access source ID + email: + type: string + description: User email + full_name: + type: string + description: User full name + role: + type: string + description: User role in the system + job_title: + type: string + description: User job title + is_admin: + type: boolean + description: Whether the user has admin privileges + mfa_status: + $ref: "#/components/schemas/MFAStatus" + description: MFA status + auth_method: + $ref: "#/components/schemas/AccessEntryAuthMethod" + description: Authentication method + account_type: + $ref: "#/components/schemas/AccessEntryAccountType" + description: Account type (user or service account) + last_login: + type: + - string + - "null" + format: date-time + description: Last login time + account_created_at: + type: + - string + - "null" + format: date-time + description: Account creation time + external_id: + type: string + description: External ID in the source system + incremental_tag: + $ref: "#/components/schemas/AccessEntryIncrementalTag" + description: Change tag compared to previous campaign + flags: + type: array + items: + $ref: "#/components/schemas/AccessEntryFlag" + description: Risk flags + flag_reasons: + type: array + items: + type: string + description: Reasons for the flags + decision: + $ref: "#/components/schemas/AccessEntryDecision" + description: Review decision + decision_note: + type: + - string + - "null" + description: Decision justification + decided_by: + anyOf: + - $ref: "#/components/schemas/GID" + - type: "null" + description: Profile ID of the decision maker + decided_at: + type: + - string + - "null" + format: date-time + description: Decision timestamp + created_at: + type: string + format: date-time + description: Creation timestamp + updated_at: + type: string + format: date-time + description: Update timestamp + + AccessEntryStatistics: + type: object + required: + - total_count + - decision_counts + - flag_counts + - incremental_tag_counts + properties: + total_count: + type: integer + description: Total number of entries + decision_counts: + type: object + additionalProperties: + type: integer + description: Count of entries per decision status + flag_counts: + type: object + additionalProperties: + type: integer + description: Count of entries per flag type + incremental_tag_counts: + type: object + additionalProperties: + type: integer + description: Count of entries per incremental tag + + ListAccessReviewCampaignsInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + order_by: + $ref: "#/components/schemas/AccessReviewCampaignOrderBy" + description: Order by + size: + type: integer + description: Page size + cursor: + $ref: "#/components/schemas/CursorKey" + description: Page cursor + + ListAccessReviewCampaignsOutput: + type: object + required: + - campaigns + properties: + next_cursor: + $ref: "#/components/schemas/CursorKey" + description: Next cursor + campaigns: + type: array + items: + $ref: "#/components/schemas/AccessReviewCampaign" + + ListAccessEntriesInput: + type: object + required: + - campaign_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + access_source_id: + $ref: "#/components/schemas/GID" + description: Filter by access source ID + order_by: + $ref: "#/components/schemas/AccessEntryOrderBy" + description: Order by + size: + type: integer + description: Page size + cursor: + $ref: "#/components/schemas/CursorKey" + description: Page cursor + filter: + type: object + properties: + decision: + $ref: "#/components/schemas/AccessEntryDecision" + description: Filter by decision status + flag: + $ref: "#/components/schemas/AccessEntryFlag" + description: Filter by flag + incremental_tag: + $ref: "#/components/schemas/AccessEntryIncrementalTag" + description: Filter by incremental tag + is_admin: + type: boolean + description: Filter by admin status + auth_method: + $ref: "#/components/schemas/AccessEntryAuthMethod" + description: Filter by auth method + account_type: + $ref: "#/components/schemas/AccessEntryAccountType" + description: Filter by account type + + ListAccessEntriesOutput: + type: object + required: + - entries + properties: + next_cursor: + $ref: "#/components/schemas/CursorKey" + description: Next cursor + entries: + type: array + items: + $ref: "#/components/schemas/AccessEntry" + + GetAccessReviewCampaignStatisticsInput: + type: object + required: + - campaign_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + + GetAccessReviewCampaignStatisticsOutput: + type: object + required: + - statistics + properties: + statistics: + $ref: "#/components/schemas/AccessEntryStatistics" + + RecordAccessEntryDecisionMCPInput: + type: object + required: + - access_entry_id + - decision + properties: + access_entry_id: + $ref: "#/components/schemas/GID" + description: Access entry ID + decision: + $ref: "#/components/schemas/AccessEntryDecision" + description: Decision (APPROVED, REVOKE, DEFER, ESCALATE) + decision_note: + type: string + description: Decision justification (required for non-APPROVED decisions) + + RecordAccessEntryDecisionMCPOutput: + type: object + required: + - access_entry + properties: + access_entry: + $ref: "#/components/schemas/AccessEntry" + + RecordAccessEntryDecisionsMCPInput: + type: object + required: + - decisions + properties: + decisions: + type: array + items: + type: object + required: + - access_entry_id + - decision + properties: + access_entry_id: + $ref: "#/components/schemas/GID" + description: Access entry ID + decision: + $ref: "#/components/schemas/AccessEntryDecision" + description: Decision (APPROVED, REVOKE, DEFER, ESCALATE) + decision_note: + type: string + description: Decision justification (required for non-APPROVED decisions) + + RecordAccessEntryDecisionsMCPOutput: + type: object + required: + - access_entries + properties: + access_entries: + type: array + items: + $ref: "#/components/schemas/AccessEntry" + + FlagAccessEntryMCPInput: + type: object + required: + - access_entry_id + - flags + properties: + access_entry_id: + $ref: "#/components/schemas/GID" + description: Access entry ID + flags: + type: array + items: + $ref: "#/components/schemas/AccessEntryFlag" + description: Flags to set (ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW, DORMANT, TERMINATED_USER, CONTRACTOR_EXPIRED, SOD_CONFLICT, PRIVILEGED_ACCESS, ROLE_CREEP, NO_BUSINESS_JUSTIFICATION, OUT_OF_DEPARTMENT, SHARED_ACCOUNT) + flag_reasons: + type: array + items: + type: string + description: Reasons for flagging + + FlagAccessEntryMCPOutput: + type: object + required: + - access_entry + properties: + access_entry: + $ref: "#/components/schemas/AccessEntry" + + CloseAccessReviewCampaignMCPInput: + type: object + required: + - campaign_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + + CloseAccessReviewCampaignMCPOutput: + type: object + required: + - campaign + properties: + campaign: + $ref: "#/components/schemas/AccessReviewCampaign" + + AccessSourceCategory: + type: string + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessSourceCategory + enum: + - SAAS + - CLOUD_INFRA + - SOURCE_CODE + - OTHER + + AccessSourceOrderField: + type: string + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessSourceOrderField + enum: + - CREATED_AT + + AccessSourceOrderBy: + type: object + required: + - field + - direction + properties: + field: + $ref: "#/components/schemas/AccessSourceOrderField" + description: Order field + direction: + $ref: "#/components/schemas/OrderDirection" + description: Order direction + + AccessSource: + type: object + required: + - id + - organization_id + - name + - created_at + - updated_at + properties: + id: + $ref: "#/components/schemas/GID" + description: Access source ID + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + connector_id: + anyOf: + - $ref: "#/components/schemas/GID" + - type: "null" + description: Connector ID + name: + type: string + description: Access source name + csv_data: + type: + - string + - "null" + description: CSV data for manual sources + created_at: + type: string + format: date-time + description: Creation timestamp + updated_at: + type: string + format: date-time + description: Update timestamp + + ListAccessSourcesInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + order_by: + $ref: "#/components/schemas/AccessSourceOrderBy" + description: Order by + size: + type: integer + description: Page size + cursor: + $ref: "#/components/schemas/CursorKey" + description: Page cursor + + ListAccessSourcesOutput: + type: object + required: + - access_sources + properties: + next_cursor: + $ref: "#/components/schemas/CursorKey" + description: Next cursor + access_sources: + type: array + items: + $ref: "#/components/schemas/AccessSource" + + CreateAccessSourceMCPInput: + type: object + required: + - organization_id + - name + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + connector_id: + $ref: "#/components/schemas/GID" + description: Connector ID (optional) + name: + type: string + description: Access source name + csv_data: + type: string + description: CSV data for manual sources (optional) + + CreateAccessSourceMCPOutput: + type: object + required: + - access_source + properties: + access_source: + $ref: "#/components/schemas/AccessSource" + + UpdateAccessSourceMCPInput: + type: object + required: + - access_source_id + properties: + access_source_id: + $ref: "#/components/schemas/GID" + description: Access source ID + name: + type: string + description: New name + connector_id: + type: + - string + - "null" + go.probo.inc/mcpgen/omittable: true + description: Connector ID (set to null to remove) + csv_data: + type: + - string + - "null" + go.probo.inc/mcpgen/omittable: true + description: CSV data for manual sources (set to null to remove) + + UpdateAccessSourceMCPOutput: + type: object + required: + - access_source + properties: + access_source: + $ref: "#/components/schemas/AccessSource" + + DeleteAccessSourceMCPInput: + type: object + required: + - access_source_id + properties: + access_source_id: + $ref: "#/components/schemas/GID" + description: Access source ID + + DeleteAccessSourceMCPOutput: + type: object + required: + - deleted_access_source_id + properties: + deleted_access_source_id: + $ref: "#/components/schemas/GID" + description: Deleted access source ID + + CreateAccessReviewCampaignMCPInput: + type: object + required: + - organization_id + - name + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + name: + type: string + description: Campaign name + description: + type: string + description: Campaign description + framework_controls: + type: array + items: + type: string + description: Framework control references + access_source_ids: + type: array + items: + $ref: "#/components/schemas/GID" + description: Access source IDs to include in scope + + CreateAccessReviewCampaignMCPOutput: + type: object + required: + - campaign + properties: + campaign: + $ref: "#/components/schemas/AccessReviewCampaign" + + UpdateAccessReviewCampaignMCPInput: + type: object + required: + - campaign_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + name: + type: string + description: New campaign name + description: + type: string + description: New campaign description + framework_controls: + type: + - array + - "null" + items: + type: string + go.probo.inc/mcpgen/omittable: true + description: Framework control references (set to null to clear) + + UpdateAccessReviewCampaignMCPOutput: + type: object + required: + - campaign + properties: + campaign: + $ref: "#/components/schemas/AccessReviewCampaign" + + DeleteAccessReviewCampaignMCPInput: + type: object + required: + - campaign_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + + DeleteAccessReviewCampaignMCPOutput: + type: object + required: + - deleted_campaign_id + properties: + deleted_campaign_id: + $ref: "#/components/schemas/GID" + description: Deleted campaign ID + + StartAccessReviewCampaignMCPInput: + type: object + required: + - campaign_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + + StartAccessReviewCampaignMCPOutput: + type: object + required: + - campaign + properties: + campaign: + $ref: "#/components/schemas/AccessReviewCampaign" + + CancelAccessReviewCampaignMCPInput: + type: object + required: + - campaign_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + + CancelAccessReviewCampaignMCPOutput: + type: object + required: + - campaign + properties: + campaign: + $ref: "#/components/schemas/AccessReviewCampaign" + + AddAccessReviewCampaignScopeSourceMCPInput: + type: object + required: + - campaign_id + - access_source_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + access_source_id: + $ref: "#/components/schemas/GID" + description: Access source ID to add to scope + + AddAccessReviewCampaignScopeSourceMCPOutput: + type: object + required: + - campaign + properties: + campaign: + $ref: "#/components/schemas/AccessReviewCampaign" + + RemoveAccessReviewCampaignScopeSourceMCPInput: + type: object + required: + - campaign_id + - access_source_id + properties: + campaign_id: + $ref: "#/components/schemas/GID" + description: Campaign ID + access_source_id: + $ref: "#/components/schemas/GID" + description: Access source ID to remove from scope + + RemoveAccessReviewCampaignScopeSourceMCPOutput: + type: object + required: + - campaign + properties: + campaign: + $ref: "#/components/schemas/AccessReviewCampaign" + OrganizationContext: type: object required: @@ -7732,6 +8569,156 @@ tools: $ref: "#/components/schemas/DeleteApplicabilityStatementInput" outputSchema: $ref: "#/components/schemas/DeleteApplicabilityStatementOutput" + - name: listAccessReviewCampaigns + description: List access review campaigns for an organization + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/ListAccessReviewCampaignsInput" + outputSchema: + $ref: "#/components/schemas/ListAccessReviewCampaignsOutput" + - name: listAccessEntries + description: List access entries for a campaign with optional filters (decision, flag, incremental_tag, is_admin, auth_method, account_type) + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/ListAccessEntriesInput" + outputSchema: + $ref: "#/components/schemas/ListAccessEntriesOutput" + - name: getAccessReviewCampaignStatistics + description: Get statistics for an access review campaign including counts by decision, flag, and incremental tag + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/GetAccessReviewCampaignStatisticsInput" + outputSchema: + $ref: "#/components/schemas/GetAccessReviewCampaignStatisticsOutput" + - name: recordAccessEntryDecision + description: Record a decision on an access entry (APPROVED, REVOKE, DEFER, or ESCALATE). Non-APPROVED decisions require a decision_note. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/RecordAccessEntryDecisionMCPInput" + outputSchema: + $ref: "#/components/schemas/RecordAccessEntryDecisionMCPOutput" + - name: recordAccessEntryDecisions + description: Record decisions on multiple access entries in a single batch. Non-APPROVED decisions require a decision_note. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/RecordAccessEntryDecisionsMCPInput" + outputSchema: + $ref: "#/components/schemas/RecordAccessEntryDecisionsMCPOutput" + - name: flagAccessEntry + description: Flag an access entry with one or more flags during review (ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW, etc.). Optionally provide reasons. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/FlagAccessEntryMCPInput" + outputSchema: + $ref: "#/components/schemas/FlagAccessEntryMCPOutput" + - name: closeAccessReviewCampaign + description: Close an access review campaign. All entries must have been decided (no PENDING entries). + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/CloseAccessReviewCampaignMCPInput" + outputSchema: + $ref: "#/components/schemas/CloseAccessReviewCampaignMCPOutput" + - name: listAccessSources + description: List access sources for an organization + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/ListAccessSourcesInput" + outputSchema: + $ref: "#/components/schemas/ListAccessSourcesOutput" + - name: createAccessSource + description: Create a new access source for an organization + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/CreateAccessSourceMCPInput" + outputSchema: + $ref: "#/components/schemas/CreateAccessSourceMCPOutput" + - name: updateAccessSource + description: Update an existing access source + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/UpdateAccessSourceMCPInput" + outputSchema: + $ref: "#/components/schemas/UpdateAccessSourceMCPOutput" + - name: deleteAccessSource + description: Delete an access source + hints: + readonly: false + destructive: true + inputSchema: + $ref: "#/components/schemas/DeleteAccessSourceMCPInput" + outputSchema: + $ref: "#/components/schemas/DeleteAccessSourceMCPOutput" + - name: createAccessReviewCampaign + description: Create a new access review campaign for an organization + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/CreateAccessReviewCampaignMCPInput" + outputSchema: + $ref: "#/components/schemas/CreateAccessReviewCampaignMCPOutput" + - name: updateAccessReviewCampaign + description: Update an existing access review campaign + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/UpdateAccessReviewCampaignMCPInput" + outputSchema: + $ref: "#/components/schemas/UpdateAccessReviewCampaignMCPOutput" + - name: deleteAccessReviewCampaign + description: Delete an access review campaign + hints: + readonly: false + destructive: true + inputSchema: + $ref: "#/components/schemas/DeleteAccessReviewCampaignMCPInput" + outputSchema: + $ref: "#/components/schemas/DeleteAccessReviewCampaignMCPOutput" + - name: startAccessReviewCampaign + description: Start an access review campaign. Triggers data fetching from all configured scope sources. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/StartAccessReviewCampaignMCPInput" + outputSchema: + $ref: "#/components/schemas/StartAccessReviewCampaignMCPOutput" + - name: cancelAccessReviewCampaign + description: Cancel an in-progress access review campaign + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/CancelAccessReviewCampaignMCPInput" + outputSchema: + $ref: "#/components/schemas/CancelAccessReviewCampaignMCPOutput" + - name: addAccessReviewCampaignScopeSource + description: Add an access source to an access review campaign's scope + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/AddAccessReviewCampaignScopeSourceMCPInput" + outputSchema: + $ref: "#/components/schemas/AddAccessReviewCampaignScopeSourceMCPOutput" + - name: removeAccessReviewCampaignScopeSource + description: Remove an access source from an access review campaign's scope + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/RemoveAccessReviewCampaignScopeSourceMCPInput" + outputSchema: + $ref: "#/components/schemas/RemoveAccessReviewCampaignScopeSourceMCPOutput" - name: getOrganizationContext description: Get the organization context containing structured sections about the company hints: diff --git a/pkg/server/api/mcp/v1/types/access_review.go b/pkg/server/api/mcp/v1/types/access_review.go new file mode 100644 index 000000000..cbd9f6e38 --- /dev/null +++ b/pkg/server/api/mcp/v1/types/access_review.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/page" +) + +func NewAccessSource(s *coredata.AccessSource) *AccessSource { + return &AccessSource{ + ID: s.ID, + OrganizationID: s.OrganizationID, + ConnectorID: s.ConnectorID, + Name: s.Name, + CsvData: s.CsvData, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} + +func NewListAccessSourcesOutput( + p *page.Page[*coredata.AccessSource, coredata.AccessSourceOrderField], +) ListAccessSourcesOutput { + sources := make([]*AccessSource, 0, len(p.Data)) + for _, s := range p.Data { + sources = append(sources, NewAccessSource(s)) + } + + var nextCursor *page.CursorKey + if len(p.Data) > 0 { + cursorKey := p.Data[len(p.Data)-1].CursorKey(p.Cursor.OrderBy.Field) + nextCursor = &cursorKey + } + + return ListAccessSourcesOutput{ + NextCursor: nextCursor, + AccessSources: sources, + } +} + +func NewAccessReviewCampaign(c *coredata.AccessReviewCampaign) *AccessReviewCampaign { + return &AccessReviewCampaign{ + ID: c.ID, + OrganizationID: c.OrganizationID, + Name: c.Name, + Description: &c.Description, + Status: c.Status, + StartedAt: c.StartedAt, + CompletedAt: c.CompletedAt, + FrameworkControls: c.FrameworkControls, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry { + entry := &AccessEntry{ + ID: e.ID, + CampaignID: e.AccessReviewCampaignID, + AccessSourceID: e.AccessSourceID, + Email: e.Email, + FullName: e.FullName, + Role: e.Role, + JobTitle: e.JobTitle, + IsAdmin: e.IsAdmin, + MfaStatus: e.MFAStatus, + AuthMethod: e.AuthMethod, + AccountType: e.AccountType, + LastLogin: e.LastLogin, + AccountCreatedAt: e.AccountCreatedAt, + ExternalID: e.ExternalID, + IncrementalTag: e.IncrementalTag, + Flags: e.Flags, + FlagReasons: e.FlagReasons, + Decision: e.Decision, + DecisionNote: e.DecisionNote, + DecidedBy: e.DecidedBy, + DecidedAt: e.DecidedAt, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } + + return entry +} + +func NewListAccessReviewCampaignsOutput( + p *page.Page[*coredata.AccessReviewCampaign, coredata.AccessReviewCampaignOrderField], +) ListAccessReviewCampaignsOutput { + campaigns := make([]*AccessReviewCampaign, 0, len(p.Data)) + for _, c := range p.Data { + campaigns = append(campaigns, NewAccessReviewCampaign(c)) + } + + var nextCursor *page.CursorKey + if len(p.Data) > 0 { + cursorKey := p.Data[len(p.Data)-1].CursorKey(p.Cursor.OrderBy.Field) + nextCursor = &cursorKey + } + + return ListAccessReviewCampaignsOutput{ + NextCursor: nextCursor, + Campaigns: campaigns, + } +} + +func NewListAccessEntriesOutput( + p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], +) ListAccessEntriesOutput { + entries := make([]*AccessEntry, 0, len(p.Data)) + for _, e := range p.Data { + entries = append(entries, NewAccessEntry(e)) + } + + var nextCursor *page.CursorKey + if len(p.Data) > 0 { + cursorKey := p.Data[len(p.Data)-1].CursorKey(p.Cursor.OrderBy.Field) + nextCursor = &cursorKey + } + + return ListAccessEntriesOutput{ + NextCursor: nextCursor, + Entries: entries, + } +} + +func NewAccessEntryStatistics(s *coredata.AccessEntryStatistics) *AccessEntryStatistics { + decisionCounts := make(map[string]any, len(s.DecisionCounts)) + for k, v := range s.DecisionCounts { + decisionCounts[string(k)] = v + } + + flagCounts := make(map[string]any, len(s.FlagCounts)) + for k, v := range s.FlagCounts { + flagCounts[string(k)] = v + } + + incrementalTagCounts := make(map[string]any, len(s.IncrementalTagCounts)) + for k, v := range s.IncrementalTagCounts { + incrementalTagCounts[string(k)] = v + } + + return &AccessEntryStatistics{ + TotalCount: s.TotalCount, + DecisionCounts: decisionCounts, + FlagCounts: flagCounts, + IncrementalTagCounts: incrementalTagCounts, + } +} diff --git a/pkg/server/api/mcp/v1/v1_handler.go b/pkg/server/api/mcp/v1/v1_handler.go index 4a0113ee5..2d2678d08 100644 --- a/pkg/server/api/mcp/v1/v1_handler.go +++ b/pkg/server/api/mcp/v1/v1_handler.go @@ -22,6 +22,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "go.gearno.de/kit/log" mcpgenmcp "go.probo.inc/mcpgen/mcp" + "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/probo" @@ -34,16 +35,17 @@ func (r *Resolver) ProboService(ctx context.Context, objectID gid.GID) *probo.Te return r.proboSvc.WithTenant(objectID.TenantID()) } -func NewMux(logger *log.Logger, proboSvc *probo.Service, iamSvc *iam.Service, tokenSecret string) *chi.Mux { +func NewMux(logger *log.Logger, proboSvc *probo.Service, iamSvc *iam.Service, accessReviewSvc *accessreview.Service, tokenSecret string) *chi.Mux { logger = logger.Named("mcp.v1") logger.Info("initializing MCP server") // server.AddReceivingMiddleware(mcputils.LoggingMiddleware(logger)) resolver := &Resolver{ - proboSvc: proboSvc, - iamSvc: iamSvc, - logger: logger, + proboSvc: proboSvc, + iamSvc: iamSvc, + accessReview: accessReviewSvc, + logger: logger, } mcpServer := server.New(resolver)