| 1 | using AgentForge.Abstractions; |
| 2 | using AgentForge.Plugin.Instance.UserManagement.Endpoints; |
| 3 | using AgentForge.Data; |
| 4 | using AgentForge.Options; |
| 5 | using AgentForge.Services; |
| 6 | using Microsoft.AspNetCore.Builder; |
| 7 | using Microsoft.Extensions.Configuration; |
| 8 | using Microsoft.Extensions.DependencyInjection; |
| 9 | using Microsoft.Extensions.Options; |
| 10 | |
| 11 | namespace AgentForge.Plugin.Instance.UserManagement; |
| 12 | |
| 13 | public sealed class InstanceUserManagementForgePlugin : IForgePlugin |
| 14 | { |
| 15 | public string Id => "instance-user-management"; |
| 16 | |
| 17 | public string DisplayName => "Instance user management (OAuth sign-in policy)"; |
| 18 | |
| 19 | public string Tier => "core"; |
| 20 | |
| 21 | public void ConfigureServices(IServiceCollection services, IConfiguration configuration) => |
| 22 | services.AddScoped<IForgeOAuthSignInGate, InstanceOAuthSignInGate>(); |
| 23 | |
| 24 | public void MapEndpoints(WebApplication app) |
| 25 | { |
| 26 | var api = app.MapGroup("/api/v1"); |
| 27 | api.MapInstanceAdminEndpoints(); |
| 28 | } |
| 29 | |
| 30 | public void RegisterFeatures(ForgeFeatureRegistry registry) => registry.Add("instance_user_management"); |
| 31 | |
| 32 | public void RegisterMcpTools(ForgeMcpToolRegistry registry) => |
| 33 | ForgeInstanceAdminMcpTools.Register(registry); |
| 34 | } |
| 35 | |
| 36 | internal sealed class InstanceOAuthSignInGate( |
| 37 | ForgeRepository repository, |
| 38 | ForgeGitHubOAuthService github, |
| 39 | IOptions<ForgeOptions> options) : IForgeOAuthSignInGate |
| 40 | { |
| 41 | public async ValueTask<ForgeOAuthSignInDecision> EvaluateAsync( |
| 42 | ForgeOAuthSignInContext context, |
| 43 | CancellationToken cancellationToken) |
| 44 | { |
| 45 | if (!string.Equals(context.Provider, "github", StringComparison.OrdinalIgnoreCase)) |
| 46 | return ForgeOAuthSignInDecision.Allow(); |
| 47 | |
| 48 | var policy = ResolvePolicy(options.Value); |
| 49 | return policy switch |
| 50 | { |
| 51 | "open" => ForgeOAuthSignInDecision.Allow(), |
| 52 | "invite_only" => EvaluateInviteOnly(context), |
| 53 | "github_org" => await EvaluateGitHubOrgAsync(context, cancellationToken).ConfigureAwait(false), |
| 54 | _ => ForgeOAuthSignInDecision.Deny($"Unknown OAuth sign-in policy '{policy}'."), |
| 55 | }; |
| 56 | } |
| 57 | |
| 58 | private static string ResolvePolicy(ForgeOptions forge) |
| 59 | { |
| 60 | if (!string.IsNullOrWhiteSpace(forge.OAuthSignInPolicy)) |
| 61 | return forge.OAuthSignInPolicy.Trim().ToLowerInvariant(); |
| 62 | |
| 63 | return forge.RequireAuth ? "invite_only" : "open"; |
| 64 | } |
| 65 | |
| 66 | private ForgeOAuthSignInDecision EvaluateInviteOnly(ForgeOAuthSignInContext context) |
| 67 | { |
| 68 | var orgSlug = options.Value.DefaultOrgSlug?.Trim(); |
| 69 | if (string.IsNullOrWhiteSpace(orgSlug)) |
| 70 | return ForgeOAuthSignInDecision.Deny("invite_only policy requires FORGE_DEFAULT_ORG_SLUG."); |
| 71 | |
| 72 | var org = repository.GetOrgBySlug(orgSlug); |
| 73 | if (org is null) |
| 74 | return ForgeOAuthSignInDecision.Deny($"Default organization '{orgSlug}' not found."); |
| 75 | |
| 76 | var identity = repository.FindIdentityByOAuthLogin(context.Provider, context.Login); |
| 77 | if (identity is not null && repository.IsInstanceAdmin(identity.Id)) |
| 78 | return ForgeOAuthSignInDecision.Allow(); |
| 79 | |
| 80 | if (identity is null || !repository.IsOrgMember(org.Id, identity.Id)) |
| 81 | return ForgeOAuthSignInDecision.Deny("Sign-in is invite-only. Ask an org admin to add you first."); |
| 82 | |
| 83 | return ForgeOAuthSignInDecision.Allow(); |
| 84 | } |
| 85 | |
| 86 | private async Task<ForgeOAuthSignInDecision> EvaluateGitHubOrgAsync( |
| 87 | ForgeOAuthSignInContext context, |
| 88 | CancellationToken cancellationToken) |
| 89 | { |
| 90 | var githubOrg = options.Value.OAuthGitHubOrg?.Trim(); |
| 91 | if (string.IsNullOrWhiteSpace(githubOrg)) |
| 92 | return ForgeOAuthSignInDecision.Deny("github_org policy requires FORGE_OAUTH_GITHUB_ORG."); |
| 93 | |
| 94 | if (string.IsNullOrWhiteSpace(context.GitHubAccessToken)) |
| 95 | return ForgeOAuthSignInDecision.Deny("GitHub access token missing for org membership check."); |
| 96 | |
| 97 | var isMember = await github.IsMemberOfOrgAsync( |
| 98 | githubOrg, |
| 99 | context.Login, |
| 100 | context.GitHubAccessToken, |
| 101 | cancellationToken).ConfigureAwait(false); |
| 102 | |
| 103 | return isMember |
| 104 | ? ForgeOAuthSignInDecision.Allow() |
| 105 | : ForgeOAuthSignInDecision.Deny($"GitHub user is not a member of organization '{githubOrg}'."); |
| 106 | } |
| 107 | } |
| 108 | |