[WIP] feat: scoped api keys

This commit is contained in:
Iris System 2025-08-17 02:47:01 -07:00
parent e7ee593a85
commit 06cb160f95
45 changed files with 1264 additions and 154 deletions

View file

@ -1,28 +1,41 @@
using System.Text;
using System.Text.RegularExpressions;
using Myriad.Builders;
using Myriad.Extensions;
using Myriad.Rest.Exceptions;
using Myriad.Rest.Types;
using Myriad.Rest.Types.Requests;
using Myriad.Types;
using NodaTime;
using SqlKata;
using PluralKit.Core;
namespace PluralKit.Bot;
public class Api
{
private record PaginatedApiKey(Guid Id, string Name, string[] Scopes, string? AppName, Instant Created);
private static readonly Regex _webhookRegex =
new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)");
private readonly BotConfig _botConfig;
private readonly DispatchService _dispatch;
private readonly InteractionDispatchService _interactions;
private readonly PrivateChannelService _dmCache;
private readonly ApiKeyService _apiKey;
public Api(BotConfig botConfig, DispatchService dispatch, PrivateChannelService dmCache)
public Api(BotConfig botConfig, DispatchService dispatch, InteractionDispatchService interactions, PrivateChannelService dmCache, ApiKeyService apiKey)
{
_botConfig = botConfig;
_dispatch = dispatch;
_interactions = interactions;
_dmCache = dmCache;
_apiKey = apiKey;
}
public async Task GetToken(Context ctx)
@ -172,4 +185,167 @@ public class Api
await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system.");
}
public async Task ApiKeyCreate(Context ctx)
{
if (!ctx.HasNext())
throw new PKSyntaxError($"An API key name must be provided.");
var rawScopes = ctx.MatchFlag("scopes", "scope");
var keyName = ctx.PopArgument();
List<string> keyScopes = new();
if (!ctx.HasNext())
throw new PKSyntaxError($"A list of API key scopes must be provided.");
var scopestr = ctx.RemainderOrNull()!.NormalizeLineEndSpacing().Trim();
if (rawScopes)
keyScopes = scopestr.Split(" ").Distinct().ToList();
else
keyScopes.Add(scopestr switch
{
"full" => "write:all",
"read private" => "read:all",
"read public" => "readpublic:all",
"identify" => "identify",
_ => throw new PKError(
$"Couldn't find a scope preset named {scopestr}."),
});
string? check = null!;
try
{
check = await _apiKey.CreateUserApiKey(ctx.System.Id, keyName, keyScopes.ToArray(), check: true);
if (check != null)
throw new PKError("API key validation failed: unknown error");
}
catch (Exception ex)
{
if (ex.Message.StartsWith("API key"))
throw new PKError(ex.Message);
throw;
}
async Task cb(InteractionContext ictx)
{
if (ictx.User.Id != ctx.Author.Id)
{
await ictx.Ignore();
return;
}
var newKey = await _apiKey.CreateUserApiKey(ctx.System.Id, keyName, keyScopes.ToArray());
await ictx.Reply($"Your new API key is below. You will only be shown this once, so please save it!\n\n||`{newKey}`||");
await ctx.Rest.EditMessage(ictx.ChannelId, ictx.MessageId!.Value, new MessageEditRequest
{
Components = new MessageComponent[] { },
});
}
var content =
$"Ready to create a new API key named **{keyName}**, "
+ $"with these scopes: {(String.Join(", ", keyScopes.Select(x => x.AsCode())))}\n"
+ "To create this API key, press the button below.";
await ctx.Rest.CreateMessage(ctx.Channel.Id, new MessageRequest
{
Content = content,
AllowedMentions = new() { Parse = new AllowedMentions.ParseType[] { }, RepliedUser = false },
Components = new[] {
new MessageComponent
{
Type = ComponentType.ActionRow,
Components = new[]
{
new MessageComponent
{
Type = ComponentType.Button,
Style = ButtonStyle.Primary,
Label = "Create API key",
CustomId = _interactions.Register(cb),
},
}
}
},
});
}
public async Task ApiKeyList(Context ctx)
{
var keys = await ctx.Repository.GetSystemApiKeys(ctx.System.Id)
.Select(k => new PaginatedApiKey(k.Id, k.Name, k.Scopes, null, k.Created))
.ToListAsync();
await ctx.Paginate<PaginatedApiKey>(
keys.ToAsyncEnumerable(),
keys.Count,
10,
"Current API keys for your system",
ctx.System.Color,
(eb, l) =>
{
var description = new StringBuilder();
foreach (var item in l)
{
description.Append($"**{item.Name}** (`{item.Id}`)");
description.AppendLine();
description.Append("- Scopes: ");
description.Append(String.Join(", ", item.Scopes.Select(sc => $"`{sc}`")));
description.AppendLine();
description.Append("- Created: ");
description.Append(item.Created.FormatZoned(ctx.Zone));
description.AppendLine();
description.AppendLine();
}
eb.Description(description.ToString());
return Task.CompletedTask;
}
);
}
public async Task ApiKeyRename(Context ctx, PKApiKey key)
{
if (!ctx.HasNext())
throw new PKError("You must provide a new name for this API key.");
var name = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
await ctx.Repository.UpdateApiKey(key.Id, new ApiKeyPatch { Name = name });
await ctx.Reply($"{Emojis.Success} API key renamed.");
}
public async Task ApiKeyDelete(Context ctx, PKApiKey key)
{
if (!await ctx.PromptYesNo($"Really delete API key **{key.Name}** `{key.Id}`?", "Delete", matchFlag: false))
{
await ctx.Reply($"{Emojis.Error} Deletion cancelled.");
return;
}
await ctx.Repository.DeleteApiKey(key.Id);
await ctx.Reply($"{Emojis.Success} Successfully deleted API key.");
}
public async Task ApiKeyDeleteAll(Context ctx)
{
if (!await ctx.PromptYesNo($"Really delete *all manually-created* API keys for your system?", "Delete", matchFlag: false))
{
await ctx.Reply($"{Emojis.Error} Deletion cancelled.");
return;
}
await ctx.BusyIndicator(async () =>
{
var query = new Query("api_keys")
.AsDelete()
.WhereRaw("[kind]::text not in ( 'dashboard', 'external_app' )")
.Where("system", ctx.System.Id);
await ctx.Database.ExecuteQuery(query);
});
await ctx.Reply($"{Emojis.Success} Successfully deleted all manually-created API keys.");
}
}