a212e145ac
Adds a dashboard event-visibility tag to ApiKeyConstraints, riding in the existing constraints JSON blob so no auth-store schema migration is needed (design docs/plans/2026-07-10-dashboard-session-acl-tst15.md sections 3/3.1, open call settled per its own recommendation). The tag is visibility-only: no read, write, browse, or subscribe path consults it, and HasRead/HasWriteConstraints ignore it. GatewaySession gains an immutable, ordinal-ignore-case Tags set stamped at construction from the owning API key, forwarded by MxAccessGatewayService.OpenSession from the resolved ApiKeyIdentity — never from the wire request, so a client cannot label its own session with another tenant's tag. ISessionManager gains a tag-carrying OpenSessionAsync overload whose default implementation forwards to the tagless one, so an implementation that does not model tags opens an untagged (least visible) session. apikey create-key gains --dashboard-tags team-a,team-b (repeatable, trimmed, de-duplicated; an empty segment is rejected rather than dropped) and list-keys prints the tags column. No enforcement yet — the EventsHub ACL that consumes the tag is a later change.
337 lines
12 KiB
C#
337 lines
12 KiB
C#
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
|
|
public static class ApiKeyAdminCommandLineParser
|
|
{
|
|
/// <summary>Parses command-line arguments for the API key admin subcommand.</summary>
|
|
/// <param name="args">Command-line arguments to parse.</param>
|
|
/// <returns>Parse result containing the command kind and options, or a failure message.</returns>
|
|
public static ApiKeyAdminParseResult Parse(IReadOnlyList<string> args)
|
|
{
|
|
if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return ApiKeyAdminParseResult.NotApiKeyCommand();
|
|
}
|
|
|
|
if (args.Count < 2)
|
|
{
|
|
return ApiKeyAdminParseResult.Fail("Missing apikey subcommand.");
|
|
}
|
|
|
|
if (!TryParseKind(args[1], out ApiKeyAdminCommandKind kind))
|
|
{
|
|
return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'.");
|
|
}
|
|
|
|
Dictionary<string, List<string?>> options = new(StringComparer.OrdinalIgnoreCase);
|
|
bool json = false;
|
|
|
|
for (int index = 2; index < args.Count; index++)
|
|
{
|
|
string arg = args[index];
|
|
if (string.Equals(arg, "--json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
json = true;
|
|
continue;
|
|
}
|
|
|
|
if (!arg.StartsWith("--", StringComparison.Ordinal))
|
|
{
|
|
return ApiKeyAdminParseResult.Fail($"Unexpected argument '{arg}'.");
|
|
}
|
|
|
|
string name = arg[2..];
|
|
string? value;
|
|
|
|
int equalsIndex = name.IndexOf('=', StringComparison.Ordinal);
|
|
if (equalsIndex >= 0)
|
|
{
|
|
value = name[(equalsIndex + 1)..];
|
|
name = name[..equalsIndex];
|
|
}
|
|
else
|
|
{
|
|
if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
|
{
|
|
if (IsBooleanConstraintFlag(name))
|
|
{
|
|
value = "true";
|
|
}
|
|
else
|
|
{
|
|
return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value.");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
value = args[++index];
|
|
}
|
|
}
|
|
|
|
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;
|
|
DateTimeOffset? expiresUtc;
|
|
try
|
|
{
|
|
constraints = ParseConstraints(options);
|
|
expiresUtc = ParseExpiry(GetOption(options, "expires"));
|
|
}
|
|
catch (FormatException exception)
|
|
{
|
|
return ApiKeyAdminParseResult.Fail(exception.Message);
|
|
}
|
|
|
|
string? validationError = Validate(kind, keyId, displayName);
|
|
if (validationError is not null)
|
|
{
|
|
return ApiKeyAdminParseResult.Fail(validationError);
|
|
}
|
|
|
|
string? scopeError = ValidateScopes(kind, scopes);
|
|
if (scopeError is not null)
|
|
{
|
|
return ApiKeyAdminParseResult.Fail(scopeError);
|
|
}
|
|
|
|
return ApiKeyAdminParseResult.Success(new ApiKeyAdminCommand(
|
|
Kind: kind,
|
|
Json: json,
|
|
SqlitePath: GetOption(options, "sqlite-path"),
|
|
Pepper: GetOption(options, "pepper"),
|
|
KeyId: keyId,
|
|
DisplayName: displayName,
|
|
Scopes: scopes,
|
|
Constraints: constraints,
|
|
ExpiresUtc: expiresUtc));
|
|
}
|
|
|
|
private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind)
|
|
{
|
|
switch (value.ToLowerInvariant())
|
|
{
|
|
case "init-db":
|
|
kind = ApiKeyAdminCommandKind.InitDb;
|
|
return true;
|
|
case "create-key":
|
|
kind = ApiKeyAdminCommandKind.CreateKey;
|
|
return true;
|
|
case "list-keys":
|
|
kind = ApiKeyAdminCommandKind.ListKeys;
|
|
return true;
|
|
case "revoke-key":
|
|
kind = ApiKeyAdminCommandKind.RevokeKey;
|
|
return true;
|
|
case "rotate-key":
|
|
kind = ApiKeyAdminCommandKind.RotateKey;
|
|
return true;
|
|
default:
|
|
kind = default;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string? Validate(ApiKeyAdminCommandKind kind, string? keyId, string? displayName)
|
|
{
|
|
if (kind is ApiKeyAdminCommandKind.CreateKey or ApiKeyAdminCommandKind.RevokeKey or ApiKeyAdminCommandKind.RotateKey
|
|
&& string.IsNullOrWhiteSpace(keyId))
|
|
{
|
|
return $"Subcommand '{KindName(kind)}' requires --key-id.";
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(keyId) && !IsValidKeyId(keyId))
|
|
{
|
|
return "API key id may contain only letters, numbers, periods, and hyphens.";
|
|
}
|
|
|
|
if (kind == ApiKeyAdminCommandKind.CreateKey && string.IsNullOrWhiteSpace(displayName))
|
|
{
|
|
return "Subcommand 'create-key' requires --display-name.";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? ValidateScopes(ApiKeyAdminCommandKind kind, IReadOnlySet<string> scopes)
|
|
{
|
|
if (kind != ApiKeyAdminCommandKind.CreateKey)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string[] unknown = scopes.Where(scope => !GatewayScopes.IsKnown(scope)).ToArray();
|
|
if (unknown.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return $"Unknown scope(s): {string.Join(", ", unknown)}. "
|
|
+ $"Valid scopes are: {string.Join(", ", GatewayScopes.All)}.";
|
|
}
|
|
|
|
private static string KindName(ApiKeyAdminCommandKind kind)
|
|
{
|
|
return kind switch
|
|
{
|
|
ApiKeyAdminCommandKind.InitDb => "init-db",
|
|
ApiKeyAdminCommandKind.CreateKey => "create-key",
|
|
ApiKeyAdminCommandKind.ListKeys => "list-keys",
|
|
ApiKeyAdminCommandKind.RevokeKey => "revoke-key",
|
|
ApiKeyAdminCommandKind.RotateKey => "rotate-key",
|
|
_ => kind.ToString()
|
|
};
|
|
}
|
|
|
|
private static bool IsValidKeyId(string keyId)
|
|
{
|
|
return keyId.All(character =>
|
|
char.IsAsciiLetterOrDigit(character)
|
|
|| character is '.' or '-');
|
|
}
|
|
|
|
private static string? GetOption(Dictionary<string, List<string?>> options, string name)
|
|
{
|
|
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"))
|
|
{
|
|
DashboardTags = ParseDashboardTags(options),
|
|
};
|
|
}
|
|
|
|
// --dashboard-tags takes a comma-separated list ("team-a,team-b"); repeating the flag unions
|
|
// its values. Segments are trimmed and de-duplicated ordinal-ignore-case, matching how the
|
|
// enforcement site compares them. An empty segment is rejected rather than dropped: a stray
|
|
// comma otherwise silently persists a grant the operator did not mean to write.
|
|
private static IReadOnlyList<string> ParseDashboardTags(Dictionary<string, List<string?>> options)
|
|
{
|
|
if (!options.TryGetValue("dashboard-tags", out List<string?>? values))
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
List<string> tags = [];
|
|
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (string? raw in values)
|
|
{
|
|
foreach (string segment in (raw ?? string.Empty).Split(','))
|
|
{
|
|
string tag = segment.Trim();
|
|
if (tag.Length == 0)
|
|
{
|
|
throw new FormatException(
|
|
"--dashboard-tags must be a comma-separated list of non-empty tags.");
|
|
}
|
|
|
|
if (seen.Add(tag))
|
|
{
|
|
tags.Add(tag);
|
|
}
|
|
}
|
|
}
|
|
|
|
return tags.Count == 0 ? Array.Empty<string>() : tags;
|
|
}
|
|
|
|
// Parses the optional --expires value into an absolute UTC expiry. Accepts a relative
|
|
// "<N>d"/"<N>h" duration from now (operator-friendly) or an absolute ISO-8601 instant/date
|
|
// (assumed UTC). Null/blank means no expiry — expiry stays opt-in, preserving prior behaviour.
|
|
private static DateTimeOffset? ParseExpiry(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string trimmed = value.Trim();
|
|
char unit = char.ToLowerInvariant(trimmed[^1]);
|
|
if (unit is 'd' or 'h'
|
|
&& int.TryParse(
|
|
trimmed[..^1],
|
|
System.Globalization.NumberStyles.None,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out int amount))
|
|
{
|
|
TimeSpan offset = unit == 'd' ? TimeSpan.FromDays(amount) : TimeSpan.FromHours(amount);
|
|
return DateTimeOffset.UtcNow + offset;
|
|
}
|
|
|
|
if (DateTimeOffset.TryParse(
|
|
trimmed,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
|
out DateTimeOffset parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
throw new FormatException(
|
|
"--expires must be a relative duration ('<N>d' or '<N>h') or an absolute ISO-8601 UTC timestamp.");
|
|
}
|
|
|
|
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)
|
|
{
|
|
return new HashSet<string>(
|
|
(scopes ?? string.Empty)
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
|
StringComparer.Ordinal);
|
|
}
|
|
}
|