Implement Galaxy filters and API key constraints

This commit is contained in:
Joseph Doherty
2026-04-29 13:37:00 -04:00
parent ac2787f619
commit b995c174eb
54 changed files with 4889 additions and 203 deletions
@@ -58,6 +58,7 @@ public sealed class ApiKeyAdminCliRunner(
SecretHash: hasher.HashSecret(secret),
DisplayName: Required(command.DisplayName),
Scopes: command.Scopes,
Constraints: command.Constraints,
CreatedUtc: DateTimeOffset.UtcNow),
cancellationToken)
.ConfigureAwait(false);
@@ -163,6 +164,7 @@ public sealed class ApiKeyAdminCliRunner(
KeyPrefix: key.KeyPrefix,
DisplayName: key.DisplayName,
Scopes: key.Scopes,
Constraints: key.Constraints,
CreatedUtc: key.CreatedUtc,
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc);
@@ -7,4 +7,5 @@ public sealed record ApiKeyAdminCommand(
string? Pepper,
string? KeyId,
string? DisplayName,
IReadOnlySet<string> Scopes);
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints);
@@ -19,7 +19,7 @@ public static class ApiKeyAdminCommandLineParser
return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'.");
}
Dictionary<string, string?> options = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, List<string?>> options = new(StringComparer.OrdinalIgnoreCase);
bool json = false;
for (int index = 2; index < args.Count; index++)
@@ -49,18 +49,42 @@ public static class ApiKeyAdminCommandLineParser
{
if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value.");
if (IsBooleanConstraintFlag(name))
{
value = "true";
}
else
{
return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value.");
}
}
else
{
value = args[++index];
}
value = args[++index];
}
options[name] = value;
if (!options.TryGetValue(name, out List<string?>? values))
{
values = [];
options[name] = values;
}
values.Add(value);
}
string? keyId = GetOption(options, "key-id");
string? displayName = GetOption(options, "display-name");
IReadOnlySet<string> scopes = ParseScopes(GetOption(options, "scopes"));
ApiKeyConstraints constraints;
try
{
constraints = ParseConstraints(options);
}
catch (FormatException exception)
{
return ApiKeyAdminParseResult.Fail(exception.Message);
}
string? validationError = Validate(kind, keyId, displayName);
if (validationError is not null)
@@ -75,7 +99,8 @@ public static class ApiKeyAdminCommandLineParser
Pepper: GetOption(options, "pepper"),
KeyId: keyId,
DisplayName: displayName,
Scopes: scopes));
Scopes: scopes,
Constraints: constraints));
}
private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind)
@@ -144,9 +169,56 @@ public static class ApiKeyAdminCommandLineParser
|| character is '.' or '-');
}
private static string? GetOption(Dictionary<string, string?> options, string name)
private static string? GetOption(Dictionary<string, List<string?>> options, string name)
{
return options.TryGetValue(name, out string? value) ? value : null;
return options.TryGetValue(name, out List<string?>? values) && values.Count > 0 ? values[^1] : null;
}
private static IReadOnlyList<string> GetOptions(Dictionary<string, List<string?>> options, string name)
{
return options.TryGetValue(name, out List<string?>? values)
? values.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).ToArray()
: Array.Empty<string>();
}
private static bool HasFlag(Dictionary<string, List<string?>> options, string name)
{
return options.ContainsKey(name);
}
private static bool IsBooleanConstraintFlag(string name)
{
return string.Equals(name, "read-alarm-only", StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, "read-historized-only", StringComparison.OrdinalIgnoreCase);
}
private static ApiKeyConstraints ParseConstraints(Dictionary<string, List<string?>> options)
{
return new ApiKeyConstraints(
ReadSubtrees: GetOptions(options, "read-subtree"),
WriteSubtrees: GetOptions(options, "write-subtree"),
ReadTagGlobs: GetOptions(options, "read-tag-glob"),
WriteTagGlobs: GetOptions(options, "write-tag-glob"),
MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")),
BrowseSubtrees: GetOptions(options, "browse-subtree"),
ReadAlarmOnly: HasFlag(options, "read-alarm-only"),
ReadHistorizedOnly: HasFlag(options, "read-historized-only"));
}
private static int? ParseNullableInt(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return int.TryParse(
value,
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out int parsed)
? parsed
: throw new FormatException("--max-write-classification must be an integer.");
}
private static IReadOnlySet<string> ParseScopes(string? scopes)
@@ -5,6 +5,7 @@ public sealed record ApiKeyAdminListedKey(
string KeyPrefix,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
@@ -0,0 +1,28 @@
using System.Text.Json;
namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyConstraintSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
};
public static string? Serialize(ApiKeyConstraints constraints)
{
ArgumentNullException.ThrowIfNull(constraints);
return constraints.IsEmpty ? null : JsonSerializer.Serialize(constraints, JsonOptions);
}
public static ApiKeyConstraints Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return ApiKeyConstraints.Empty;
}
return JsonSerializer.Deserialize<ApiKeyConstraints>(json, JsonOptions) ?? ApiKeyConstraints.Empty;
}
}
@@ -0,0 +1,43 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyConstraints(
IReadOnlyList<string> ReadSubtrees,
IReadOnlyList<string> WriteSubtrees,
IReadOnlyList<string> ReadTagGlobs,
IReadOnlyList<string> WriteTagGlobs,
int? MaxWriteClassification,
IReadOnlyList<string> BrowseSubtrees,
bool ReadAlarmOnly,
bool ReadHistorizedOnly)
{
public static ApiKeyConstraints Empty { get; } = new(
ReadSubtrees: Array.Empty<string>(),
WriteSubtrees: Array.Empty<string>(),
ReadTagGlobs: Array.Empty<string>(),
WriteTagGlobs: Array.Empty<string>(),
MaxWriteClassification: null,
BrowseSubtrees: Array.Empty<string>(),
ReadAlarmOnly: false,
ReadHistorizedOnly: false);
public bool IsEmpty =>
ReadSubtrees.Count == 0
&& WriteSubtrees.Count == 0
&& ReadTagGlobs.Count == 0
&& WriteTagGlobs.Count == 0
&& MaxWriteClassification is null
&& BrowseSubtrees.Count == 0
&& !ReadAlarmOnly
&& !ReadHistorizedOnly;
public bool HasReadConstraints =>
ReadSubtrees.Count > 0
|| ReadTagGlobs.Count > 0
|| ReadAlarmOnly
|| ReadHistorizedOnly;
public bool HasWriteConstraints =>
WriteSubtrees.Count > 0
|| WriteTagGlobs.Count > 0
|| MaxWriteClassification is not null;
}
@@ -6,4 +6,5 @@ public sealed record ApiKeyCreateRequest(
byte[] SecretHash,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc);
@@ -4,4 +4,8 @@ public sealed record ApiKeyIdentity(
string KeyId,
string KeyPrefix,
string DisplayName,
IReadOnlySet<string> Scopes);
IReadOnlySet<string> Scopes,
ApiKeyConstraints? Constraints = null)
{
public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty;
}
@@ -6,6 +6,7 @@ public sealed record ApiKeyRecord(
byte[] SecretHash,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
@@ -12,9 +12,10 @@ public static class ApiKeyRecordReader
SecretHash: (byte[])reader["secret_hash"],
DisplayName: reader.GetString(3),
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture),
LastUsedUtc: ReadNullableDateTimeOffset(reader, 6),
RevokedUtc: ReadNullableDateTimeOffset(reader, 7));
Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
}
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
@@ -52,6 +52,7 @@ public sealed class ApiKeyVerifier(
KeyId: storedKey.KeyId,
KeyPrefix: storedKey.KeyPrefix,
DisplayName: storedKey.DisplayName,
Scopes: storedKey.Scopes));
Scopes: storedKey.Scopes,
Constraints: storedKey.Constraints));
}
}
@@ -17,6 +17,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
secret_hash,
display_name,
scopes,
constraints,
created_utc,
last_used_utc,
revoked_utc)
@@ -26,6 +27,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
$secret_hash,
$display_name,
$scopes,
$constraints,
$created_utc,
NULL,
NULL);
@@ -42,7 +44,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
FROM api_keys
ORDER BY key_id;
""";
@@ -111,6 +113,9 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash;
command.Parameters.AddWithValue("$display_name", request.DisplayName);
command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes));
command.Parameters.AddWithValue(
"$constraints",
(object?)ApiKeyConstraintSerializer.Serialize(request.Constraints) ?? DBNull.Value);
command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O"));
}
}
@@ -42,12 +42,12 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = requireActive
? """
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
FROM api_keys
WHERE key_id = $key_id AND revoked_utc IS NULL;
"""
: """
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
FROM api_keys
WHERE key_id = $key_id;
""";
@@ -2,7 +2,7 @@ namespace MxGateway.Server.Security.Authentication;
public static class SqliteAuthSchema
{
public const int CurrentVersion = 1;
public const int CurrentVersion = 2;
public const string SchemaVersionTable = "schema_version";
@@ -22,6 +22,8 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
}
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await ApplyVersionTwoAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
@@ -83,6 +85,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
secret_hash BLOB NOT NULL,
display_name TEXT NOT NULL,
scopes TEXT NOT NULL,
constraints TEXT NULL,
created_utc TEXT NOT NULL,
last_used_utc TEXT NULL,
revoked_utc TEXT NULL
@@ -105,6 +108,34 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
""",
cancellationToken).ConfigureAwait(false);
}
private static async Task ApplyVersionTwoAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
if (await ColumnExistsAsync(connection, transaction, SqliteAuthSchema.ApiKeysTable, "constraints", cancellationToken)
.ConfigureAwait(false))
{
return;
}
await ExecuteNonQueryAsync(
connection,
transaction,
"""
ALTER TABLE api_keys
ADD COLUMN constraints TEXT NULL;
""",
cancellationToken).ConfigureAwait(false);
}
private static async Task WriteSchemaVersionAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await using SqliteCommand versionCommand = connection.CreateCommand();
versionCommand.Transaction = transaction;
versionCommand.CommandText = """
@@ -120,6 +151,31 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> ColumnExistsAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string tableName,
string columnName,
CancellationToken cancellationToken)
{
await using SqliteCommand command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"PRAGMA table_info({tableName});";
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
.ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static async Task ExecuteNonQueryAsync(
SqliteConnection connection,
SqliteTransaction transaction,