up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -24,11 +24,17 @@ internal static class CommandFactory
|
||||
Description = "Enable verbose logging output."
|
||||
};
|
||||
|
||||
var globalTenantOption = new Option<string?>("--tenant", new[] { "-t" })
|
||||
{
|
||||
Description = "Tenant context for the operation. Overrides profile and STELLAOPS_TENANT environment variable."
|
||||
};
|
||||
|
||||
var root = new RootCommand("StellaOps command-line interface")
|
||||
{
|
||||
TreatUnmatchedTokensAsErrors = true
|
||||
};
|
||||
root.Add(verboseOption);
|
||||
root.Add(globalTenantOption);
|
||||
|
||||
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
|
||||
@@ -38,6 +44,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildTenantsCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
|
||||
@@ -772,6 +779,74 @@ internal static class CommandFactory
|
||||
return auth;
|
||||
}
|
||||
|
||||
private static Command BuildTenantsCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = options;
|
||||
var tenants = new Command("tenants", "Manage tenant contexts (CLI-TEN-47-001).");
|
||||
|
||||
var list = new Command("list", "List available tenants for the authenticated principal.");
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context to use for the request (required for multi-tenant environments)."
|
||||
};
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output tenant list in JSON format."
|
||||
};
|
||||
|
||||
list.Add(tenantOption);
|
||||
list.Add(jsonOption);
|
||||
|
||||
list.SetAction((parseResult, _) =>
|
||||
{
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleTenantsListAsync(services, options, tenant, json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var use = new Command("use", "Set the active tenant context for subsequent commands.");
|
||||
var tenantIdArgument = new Argument<string>("tenant-id")
|
||||
{
|
||||
Description = "Tenant identifier to use as the default context."
|
||||
};
|
||||
use.Add(tenantIdArgument);
|
||||
|
||||
use.SetAction((parseResult, _) =>
|
||||
{
|
||||
var tenantId = parseResult.GetValue(tenantIdArgument) ?? string.Empty;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleTenantsUseAsync(services, options, tenantId, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var current = new Command("current", "Show the currently active tenant context.");
|
||||
var currentJsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output profile in JSON format."
|
||||
};
|
||||
current.Add(currentJsonOption);
|
||||
|
||||
current.SetAction((parseResult, _) =>
|
||||
{
|
||||
var json = parseResult.GetValue(currentJsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleTenantsCurrentAsync(json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var clear = new Command("clear", "Clear the active tenant context (use default or require --tenant).");
|
||||
|
||||
clear.SetAction((_, _) =>
|
||||
{
|
||||
return CommandHandlers.HandleTenantsClearAsync(cancellationToken);
|
||||
});
|
||||
|
||||
tenants.Add(list);
|
||||
tenants.Add(use);
|
||||
tenants.Add(current);
|
||||
tenants.Add(clear);
|
||||
return tenants;
|
||||
}
|
||||
|
||||
private static Command BuildPolicyCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = options;
|
||||
|
||||
@@ -2205,6 +2205,237 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTenantsListAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string? tenant,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-list");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var client = scope.ServiceProvider.GetService<IAuthorityConsoleClient>();
|
||||
if (client is null)
|
||||
{
|
||||
logger.LogError("Authority console client is not available. Ensure Authority is configured and services are registered.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
||||
if (string.IsNullOrWhiteSpace(effectiveTenant))
|
||||
{
|
||||
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT environment variable, or run 'stella tenants use <tenant-id>'.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tenants = await client.ListTenantsAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
var output = new { tenants = tenants };
|
||||
var jsonText = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
|
||||
Console.WriteLine(jsonText);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No tenants available for the authenticated principal.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Available tenants ({Count}):", tenants.Count);
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
var status = string.Equals(t.Status, "active", StringComparison.OrdinalIgnoreCase) ? "" : $" ({t.Status})";
|
||||
logger.LogInformation(" {Id}: {DisplayName}{Status}", t.Id, t.DisplayName, status);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation(" Isolation: {IsolationMode}", t.IsolationMode);
|
||||
if (t.DefaultRoles.Count > 0)
|
||||
{
|
||||
logger.LogInformation(" Default roles: {Roles}", string.Join(", ", t.DefaultRoles));
|
||||
}
|
||||
if (t.Projects.Count > 0)
|
||||
{
|
||||
logger.LogInformation(" Projects: {Projects}", string.Join(", ", t.Projects));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
logger.LogError("Authentication required. Run 'stella auth login' first.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
logger.LogError("Access denied. The authenticated principal does not have permission to list tenants.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to retrieve tenant list: {Message}", ex.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTenantsUseAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string tenantId,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-use");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
logger.LogError("Tenant identifier is required.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
|
||||
string? displayName = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
var client = scope.ServiceProvider.GetService<IAuthorityConsoleClient>();
|
||||
if (client is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenants = await client.ListTenantsAsync(normalizedTenant, cancellationToken).ConfigureAwait(false);
|
||||
var match = tenants.FirstOrDefault(t =>
|
||||
string.Equals(t.Id, normalizedTenant, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match is not null)
|
||||
{
|
||||
displayName = match.DisplayName;
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogDebug("Validated tenant '{TenantId}' with display name '{DisplayName}'.", normalizedTenant, displayName);
|
||||
}
|
||||
}
|
||||
else if (verbose)
|
||||
{
|
||||
logger.LogWarning("Tenant '{TenantId}' not found in available tenants. Setting anyway.", normalizedTenant);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogWarning("Could not validate tenant against Authority: {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await TenantProfileStore.SetActiveTenantAsync(normalizedTenant, displayName, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Active tenant set to '{TenantId}'.", normalizedTenant);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
logger.LogInformation("Tenant display name: {DisplayName}", displayName);
|
||||
}
|
||||
|
||||
logger.LogInformation("Profile saved to: {Path}", TenantProfileStore.GetProfilePath());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to save tenant profile: {Message}", ex.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTenantsCurrentAsync(
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var profile = await TenantProfileStore.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
var output = profile ?? new TenantProfile();
|
||||
var jsonText = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
|
||||
Console.WriteLine(jsonText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile is null || string.IsNullOrWhiteSpace(profile.ActiveTenant))
|
||||
{
|
||||
Console.WriteLine("No active tenant configured.");
|
||||
Console.WriteLine("Use 'stella tenants use <tenant-id>' to set one.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Active tenant: {profile.ActiveTenant}");
|
||||
if (!string.IsNullOrWhiteSpace(profile.ActiveTenantDisplayName))
|
||||
{
|
||||
Console.WriteLine($"Display name: {profile.ActiveTenantDisplayName}");
|
||||
}
|
||||
|
||||
if (profile.LastUpdated.HasValue)
|
||||
{
|
||||
Console.WriteLine($"Last updated: {profile.LastUpdated.Value:u}");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Profile path: {TenantProfileStore.GetProfilePath()}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to load tenant profile: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTenantsClearAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
try
|
||||
{
|
||||
await TenantProfileStore.ClearActiveTenantAsync(cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine("Active tenant cleared.");
|
||||
Console.WriteLine("Subsequent commands will require --tenant or STELLAOPS_TENANT environment variable.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to clear tenant profile: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleVulnObservationsAsync(
|
||||
IServiceProvider services,
|
||||
string tenant,
|
||||
@@ -8315,6 +8546,21 @@ internal static class CommandHandlers
|
||||
diag.Code ?? "-",
|
||||
diag.Path ?? "-",
|
||||
Markup.Escape(diag.Message));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
}
|
||||
|
||||
return result.Success ? ExitSuccess : ExitValidationError;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
|
||||
return ExitInputError;
|
||||
}
|
||||
}
|
||||
|
||||
#region Risk Profile Commands
|
||||
|
||||
public static async Task HandleRiskProfileValidateAsync(
|
||||
@@ -8417,16 +8663,12 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
return result.Success ? ExitSuccess : ExitValidationError;
|
||||
Environment.ExitCode = result.IsValid ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return ExitInputError;
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8919,33 +9161,6 @@ internal static class CommandHandlers
|
||||
public JsonElement? ExpectedFindings { get; set; }
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
await File.WriteAllTextAsync(outputPath, reportJson).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine("Validation report written to [cyan]{0}[/]", Markup.Escape(outputPath));
|
||||
}
|
||||
}
|
||||
|
||||
Environment.ExitCode = result.IsValid ? 0 : (strict ? 1 : 0);
|
||||
if (!result.IsValid && !strict)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task HandleRiskProfileSchemaAsync(string? outputPath, bool verbose)
|
||||
{
|
||||
_ = verbose;
|
||||
|
||||
@@ -13,16 +13,16 @@ using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Cli;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
internal static async Task<int> Main(string[] args)
|
||||
{
|
||||
var (options, configuration) = CliBootstrapper.Build(args);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
namespace StellaOps.Cli;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
internal static async Task<int> Main(string[] args)
|
||||
{
|
||||
var (options, configuration) = CliBootstrapper.Build(args);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(configuration);
|
||||
services.AddSingleton(options);
|
||||
services.AddOptions();
|
||||
@@ -31,7 +31,7 @@ internal static class Program
|
||||
services.AddSingleton(verbosityState);
|
||||
services.AddAirGapEgressPolicy(configuration);
|
||||
services.AddStellaOpsCrypto(options.Crypto);
|
||||
|
||||
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
@@ -96,6 +96,15 @@ internal static class Program
|
||||
client.BaseAddress = authorityUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "authority-revocation");
|
||||
|
||||
services.AddHttpClient<IAuthorityConsoleClient, AuthorityConsoleClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
if (Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
client.BaseAddress = authorityUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "authority-console");
|
||||
}
|
||||
|
||||
services.AddHttpClient<IBackendOperationsClient, BackendOperationsClient>(client =>
|
||||
@@ -127,7 +136,7 @@ internal static class Program
|
||||
|
||||
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
|
||||
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
|
||||
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
|
||||
@@ -137,8 +146,8 @@ internal static class Program
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token, loggerFactory);
|
||||
var commandConfiguration = new CommandLineConfiguration(rootCommand);
|
||||
int commandExit;
|
||||
@@ -164,13 +173,13 @@ internal static class Program
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var finalExit = Environment.ExitCode != 0 ? Environment.ExitCode : commandExit;
|
||||
if (cts.IsCancellationRequested && finalExit == 0)
|
||||
{
|
||||
finalExit = 130; // Typical POSIX cancellation exit code
|
||||
}
|
||||
|
||||
|
||||
var finalExit = Environment.ExitCode != 0 ? Environment.ExitCode : commandExit;
|
||||
if (cts.IsCancellationRequested && finalExit == 0)
|
||||
{
|
||||
finalExit = 130; // Typical POSIX cancellation exit code
|
||||
}
|
||||
|
||||
return finalExit;
|
||||
}
|
||||
|
||||
|
||||
41
src/Cli/StellaOps.Cli/Services/AuthorityConsoleClient.cs
Normal file
41
src/Cli/StellaOps.Cli/Services/AuthorityConsoleClient.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for Authority console endpoints (CLI-TEN-47-001).
|
||||
/// </summary>
|
||||
internal sealed class AuthorityConsoleClient : IAuthorityConsoleClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public AuthorityConsoleClient(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TenantInfo>> ListTenantsAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "console/tenants");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
request.Headers.Add("X-StellaOps-Tenant", tenant.Trim().ToLowerInvariant());
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content
|
||||
.ReadFromJsonAsync<TenantListResponse>(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result?.Tenants ?? Array.Empty<TenantInfo>();
|
||||
}
|
||||
}
|
||||
17
src/Cli/StellaOps.Cli/Services/IAuthorityConsoleClient.cs
Normal file
17
src/Cli/StellaOps.Cli/Services/IAuthorityConsoleClient.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Authority console endpoints (CLI-TEN-47-001).
|
||||
/// </summary>
|
||||
internal interface IAuthorityConsoleClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists available tenants for the authenticated principal.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TenantInfo>> ListTenantsAsync(string tenant, CancellationToken cancellationToken);
|
||||
}
|
||||
37
src/Cli/StellaOps.Cli/Services/Models/TenantModels.cs
Normal file
37
src/Cli/StellaOps.Cli/Services/Models/TenantModels.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Response from GET /console/tenants endpoint.
|
||||
/// </summary>
|
||||
internal sealed record TenantListResponse(
|
||||
[property: JsonPropertyName("tenants")] IReadOnlyList<TenantInfo> Tenants);
|
||||
|
||||
/// <summary>
|
||||
/// Tenant metadata as returned by the Authority service.
|
||||
/// </summary>
|
||||
internal sealed record TenantInfo(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("displayName")] string DisplayName,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("isolationMode")] string IsolationMode,
|
||||
[property: JsonPropertyName("defaultRoles")] IReadOnlyList<string> DefaultRoles,
|
||||
[property: JsonPropertyName("projects")] IReadOnlyList<string> Projects);
|
||||
|
||||
/// <summary>
|
||||
/// Persistent tenant profile stored at ~/.stellaops/profile.json.
|
||||
/// </summary>
|
||||
internal sealed record TenantProfile
|
||||
{
|
||||
[JsonPropertyName("activeTenant")]
|
||||
public string? ActiveTenant { get; init; }
|
||||
|
||||
[JsonPropertyName("activeTenantDisplayName")]
|
||||
public string? ActiveTenantDisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTimeOffset? LastUpdated { get; init; }
|
||||
}
|
||||
137
src/Cli/StellaOps.Cli/Services/TenantProfileStore.cs
Normal file
137
src/Cli/StellaOps.Cli/Services/TenantProfileStore.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Stores and retrieves the active tenant profile at ~/.stellaops/profile.json.
|
||||
/// CLI-TEN-47-001: Persistent profiles implementation.
|
||||
/// </summary>
|
||||
internal static class TenantProfileStore
|
||||
{
|
||||
private const string ProfileFileName = "profile.json";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public static string GetProfileDirectory()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(home))
|
||||
{
|
||||
home = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(home, ".stellaops"));
|
||||
}
|
||||
|
||||
public static string GetProfilePath()
|
||||
=> Path.Combine(GetProfileDirectory(), ProfileFileName);
|
||||
|
||||
public static async Task<TenantProfile?> LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = GetProfilePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
return await JsonSerializer.DeserializeAsync<TenantProfile>(stream, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static TenantProfile? Load()
|
||||
{
|
||||
var path = GetProfilePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<TenantProfile>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SaveAsync(TenantProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var directory = GetProfileDirectory();
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var path = GetProfilePath();
|
||||
await using var stream = File.Create(path);
|
||||
await JsonSerializer.SerializeAsync(stream, profile, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task SetActiveTenantAsync(string tenantId, string? displayName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
ActiveTenant = tenantId?.Trim().ToLowerInvariant(),
|
||||
ActiveTenantDisplayName = displayName?.Trim(),
|
||||
LastUpdated = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await SaveAsync(profile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task ClearActiveTenantAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
ActiveTenant = null,
|
||||
ActiveTenantDisplayName = null,
|
||||
LastUpdated = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await SaveAsync(profile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string? GetEffectiveTenant(string? commandLineTenant)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(commandLineTenant))
|
||||
{
|
||||
return commandLineTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
var envTenant = Environment.GetEnvironmentVariable("STELLAOPS_TENANT");
|
||||
if (!string.IsNullOrWhiteSpace(envTenant))
|
||||
{
|
||||
return envTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
var profile = Load();
|
||||
return profile?.ActiveTenant;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user