diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c2ca2..89fefb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### New features +- **`whoami` and `can-i` access diagnostics.** `whoami` reports the current credential type and, for Microsoft Entra ID connections, the principal, tenant, application id, user principal name, display name, and token expiry decoded from the Cosmos DB access token. `can-i ` probes data-plane access with safe, non-mutating requests and reports `allow`, `deny`, or `indeterminate`. Both commands are data-plane only (no control-plane dependency): account-key and emulator connections are reported from the master key, and RBAC role assignments are not enumerated. Both support `--format` (`table`, `json`, or `csv`). ([#163](https://github.com/Azure/CosmosDBShell/issues/163)) - **Deterministic machine output and exit codes.** Global `--output`/`--quiet`, structured JSON/CSV machine mode, and stable process exit codes (`0`–`6`) for automation and CI. ([#173](https://github.com/Azure/CosmosDBShell/pull/173), [#155](https://github.com/Azure/CosmosDBShell/issues/155), [#176](https://github.com/Azure/CosmosDBShell/issues/176), [#177](https://github.com/Azure/CosmosDBShell/issues/177)) - **`setup-cosmosdb-shell` GitHub Action** and [CI/CD guide](docs/ci.md) for installing the self-contained shell in pipelines without a .NET SDK on the runner. ([#173](https://github.com/Azure/CosmosDBShell/pull/173)) @@ -90,6 +91,8 @@ A focused cycle on top of 1.1.115-preview. New `ttl` and `conflict` commands man - **Structured (JSON) tool results for MCP.** MCP tool results now carry the machine-readable JSON payload (`result`/`outputText`/`error` plus `currentLocation`) as first-class `structuredContent` in addition to the existing JSON text block, so agents can consume structured results directly. The two representations are kept byte-for-byte equivalent, and text-only clients are unaffected. ([#154](https://github.com/Azure/CosmosDBShell/issues/154)) +- **Destructive MCP commands now prompt for confirmation instead of being blocked.** When an MCP client invokes `delete`, `rm`, `rmcon`, or `rmdb`, the server sends an elicitation prompt describing the exact command line and only runs it if the user approves; declining, cancelling, or a client that cannot confirm results in nothing being executed. This removes the need for any write opt-in flag. ([#158](https://github.com/Azure/CosmosDBShell/issues/158)) + ### Fixes - MCP clients that reject unknown protocol versions (for example, Claude Code) can now connect: the server no longer advertises an unsupported protocol version. ([#150](https://github.com/Azure/CosmosDBShell/pull/150)) diff --git a/CosmosDBShell.Tests/CommandTests/CanICommandTests.cs b/CosmosDBShell.Tests/CommandTests/CanICommandTests.cs new file mode 100644 index 0000000..04159be --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/CanICommandTests.cs @@ -0,0 +1,156 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Microsoft.Azure.Cosmos; + +/// +/// Offline unit tests for . These cover input +/// validation, the non-probeable 'manage' action, and the master-key branch. +/// The live data-plane probes (read/query/write) are exercised by the emulator +/// integration tests. +/// +public class CanICommandTests +{ + [Fact] + public async Task CanI_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new CanICommand { Action = "read" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "can-i read", CancellationToken.None)); + } + + [Fact] + public async Task CanI_InvalidAction_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ConnectedState(CreateTestClient()); + var command = new CanICommand { Action = "delete" }; + + var exception = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "can-i delete", CancellationToken.None)); + + Assert.Equal( + MessageService.GetArgsString("command-can-i-invalid-action", "action", "delete"), + exception.Message); + } + + [Fact] + public async Task CanI_Manage_ReportsIndeterminate() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.StdOutRedirect = "out.txt"; + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new CanICommand { Action = "manage" }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "can-i manage", CancellationToken.None); + + var json = Assert.IsType(state.Result).Value; + Assert.Equal("manage", json.GetProperty("action").GetString()); + Assert.Equal("indeterminate", json.GetProperty("decision").GetString()); + Assert.Equal("none", json.GetProperty("method").GetString()); + } + + [Fact] + public async Task CanI_ReadWithoutContainer_ThrowsCommandException() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new ConnectedState(CreateTestClient()); + var command = new CanICommand { Action = "read" }; + + var exception = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "can-i read", CancellationToken.None)); + + Assert.Equal( + MessageService.GetString("command-can-i-requires-container"), + exception.Message); + } + + [Fact] + public async Task CanI_KeyAuth_ReportsAllow() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.StdOutRedirect = "out.txt"; + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new CanICommand { Action = "read", Database = "MyDB", Container = "Products" }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "can-i read --database=MyDB --container=Products", CancellationToken.None); + + var json = Assert.IsType(state.Result).Value; + Assert.Equal("allow", json.GetProperty("decision").GetString()); + Assert.Equal("key", json.GetProperty("method").GetString()); + Assert.Equal("MyDB", json.GetProperty("database").GetString()); + Assert.Equal("Products", json.GetProperty("container").GetString()); + } + + [Fact] + public async Task CanI_WithoutCommandFormat_DefersToSessionDefault() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new CanICommand { Action = "manage" }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "can-i manage", CancellationToken.None); + + Assert.False(state.OutputFormatExplicitlySet); + } + + [Fact] + public async Task CanI_CsvFormat_SetsCsvOutput() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new CanICommand { Action = "read", Database = "MyDB", Container = "Products", OutputFormat = "csv" }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "can-i read --database=MyDB --container=Products --format=csv", CancellationToken.None); + + Assert.Equal(OutputFormat.CSV, state.OutputFormat); + Assert.NotNull(state.RenderUser); + + var csv = state.GenerateOutputText(); + Assert.Contains("decision", csv, StringComparison.Ordinal); + Assert.Contains("allow", csv, StringComparison.Ordinal); + } + + [Fact] + public async Task CanI_JsonFormat_SetsJsonOutput() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new CanICommand { Action = "manage", OutputFormat = "json" }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "can-i manage --format=json", CancellationToken.None); + + Assert.Equal(OutputFormat.JSon, state.OutputFormat); + Assert.NotNull(state.RenderUser); + Assert.Contains("\"decision\"", state.GenerateOutputText(), StringComparison.Ordinal); + } + + [Fact] + public async Task CanI_InvalidFormat_Throws() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new CanICommand { Action = "manage", OutputFormat = "xml" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "can-i manage --format=xml", CancellationToken.None)); + } + + private static CosmosClient CreateTestClient() + { + var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/"); + return new CosmosClient(connectionString); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/WhoamiCommandTests.cs b/CosmosDBShell.Tests/CommandTests/WhoamiCommandTests.cs new file mode 100644 index 0000000..b932326 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/WhoamiCommandTests.cs @@ -0,0 +1,111 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Microsoft.Azure.Cosmos; + +/// +/// Offline unit tests for . These cover the +/// not-connected branch and the key/emulator branch where no Entra identity is +/// available. The token-decoding path is exercised through +/// tests; live-identity introspection is covered by integration tests. +/// +public class WhoamiCommandTests +{ + [Fact] + public async Task Whoami_Disconnected_ThrowsNotConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.State = new DisconnectedState(); + var command = new WhoamiCommand(); + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "whoami", CancellationToken.None)); + } + + [Fact] + public async Task Whoami_KeyAuth_ReportsNoIdentity() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.StdOutRedirect = "out.txt"; + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new WhoamiCommand(); + + var state = await command.ExecuteAsync(shell, new CommandState(), "whoami", CancellationToken.None); + + var json = Assert.IsType(state.Result).Value; + Assert.Equal("AccountKey", json.GetProperty("credentialType").GetString()); + Assert.False(json.GetProperty("identityAvailable").GetBoolean()); + Assert.Equal( + MessageService.GetString("command-whoami-key-auth-note"), + json.GetProperty("note").GetString()); + } + + [Fact] + public async Task Whoami_WithoutCommandFormat_DefersToSessionDefault() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new WhoamiCommand(); + + var state = await command.ExecuteAsync(shell, new CommandState(), "whoami", CancellationToken.None); + + Assert.False(state.OutputFormatExplicitlySet); + } + + [Fact] + public async Task Whoami_JsonFormat_SetsJsonOutput() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new WhoamiCommand { OutputFormat = "json" }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "whoami --format=json", CancellationToken.None); + + Assert.Equal(OutputFormat.JSon, state.OutputFormat); + Assert.NotNull(state.RenderUser); + Assert.Contains("\"credentialType\"", state.GenerateOutputText(), StringComparison.Ordinal); + } + + [Fact] + public async Task Whoami_CsvFormat_SetsCsvOutput() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new WhoamiCommand { OutputFormat = "csv" }; + + var state = await command.ExecuteAsync(shell, new CommandState(), "whoami --format=csv", CancellationToken.None); + + Assert.Equal(OutputFormat.CSV, state.OutputFormat); + Assert.NotNull(state.RenderUser); + + var csv = state.GenerateOutputText(); + Assert.Contains("credentialType", csv, StringComparison.Ordinal); + Assert.Contains("AccountKey", csv, StringComparison.Ordinal); + } + + [Fact] + public async Task Whoami_InvalidFormat_Throws() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey"); + var command = new WhoamiCommand { OutputFormat = "xml" }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "whoami --format=xml", CancellationToken.None)); + } + + private static CosmosClient CreateTestClient() + { + var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/"); + return new CosmosClient(connectionString); + } +} diff --git a/CosmosDBShell.Tests/UtilTest/JwtClaimsTests.cs b/CosmosDBShell.Tests/UtilTest/JwtClaimsTests.cs new file mode 100644 index 0000000..bae1dde --- /dev/null +++ b/CosmosDBShell.Tests/UtilTest/JwtClaimsTests.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.UtilTest; + +using System.Text; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Util; + +/// +/// Unit tests for , the JWT payload reader used by the +/// whoami command. The helper decodes claims for local introspection only +/// and never validates the token signature. +/// +public class JwtClaimsTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-jwt")] + [InlineData("only.two")] + [InlineData("a.!!!.c")] + public void TryDecodePayload_MalformedToken_ReturnsNull(string? token) + { + Assert.Null(JwtClaims.TryDecodePayload(token)); + } + + [Fact] + public void TryDecodePayload_ValidToken_ReturnsClaims() + { + var token = BuildToken("{\"oid\":\"abc\",\"tid\":\"tenant\"}"); + + var claims = JwtClaims.TryDecodePayload(token); + + Assert.NotNull(claims); + Assert.Equal("abc", JwtClaims.GetString(claims, "oid")); + Assert.Equal("tenant", JwtClaims.GetString(claims, "tid")); + } + + [Fact] + public void GetString_TriesCandidatesInOrder() + { + var token = BuildToken("{\"preferred_username\":\"user@contoso.com\"}"); + var claims = JwtClaims.TryDecodePayload(token); + + Assert.Equal( + "user@contoso.com", + JwtClaims.GetString(claims, "upn", "preferred_username", "unique_name")); + } + + [Fact] + public void GetString_MissingClaim_ReturnsNull() + { + var token = BuildToken("{\"oid\":\"abc\"}"); + var claims = JwtClaims.TryDecodePayload(token); + + Assert.Null(JwtClaims.GetString(claims, "upn")); + } + + [Fact] + public void GetString_NonStringClaim_ReturnsNull() + { + var token = BuildToken("{\"oid\":42}"); + var claims = JwtClaims.TryDecodePayload(token); + + Assert.Null(JwtClaims.GetString(claims, "oid")); + } + + private static string BuildToken(string payloadJson) + { + var payload = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + return $"header.{payload}.signature"; + } + + private static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs new file mode 100644 index 0000000..8df38de --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs @@ -0,0 +1,242 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Commands; + +using System.Collections.Generic; +using System.Net; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Mcp; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Spectre.Console; + +[CosmosCommand("can-i")] +[CosmosExample("can-i read", Description = "Probe whether the current identity can read items in the current container")] +[CosmosExample("can-i query --database=MyDB --container=Products", Description = "Probe query access against a specific container")] +[CosmosExample("can-i write", Description = "Probe write access using a safe, non-mutating operation")] +[CosmosExample("can-i read --format=json", Description = "Emit the access check as a JSON object")] +[McpAnnotation( + Description = @" +Probes whether the current identity can perform an action against a container without mutating data. + +Actions: read, query, write, manage. The probe issues a safe, non-mutating data-plane request (a point read of a random id, +a minimal TOP 1 query, or a delete of a random non-existent id) and reports allow, deny, or indeterminate based on the response. + +The 'write' result is a heuristic derived from delete permission. The 'manage' action cannot be probed on the data plane and is +reported as indeterminate. Account-key and emulator connections use a master key and are reported as allow. Use --format +(table, json, csv) to control the output.")] +internal class CanICommand : CosmosCommand +{ + [CosmosParameter("action")] + public string Action { get; init; } = string.Empty; + + [CosmosOption("database", "db")] + public string? Database { get; init; } + + [CosmosOption("container", "con")] + public string? Container { get; init; } + + [CosmosOption("format", "f")] + public string? OutputFormat { get; init; } + + public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + if (shell.State is not ConnectedState connectedState) + { + throw new NotConnectedException("can-i"); + } + + var action = (this.Action ?? string.Empty).Trim().ToLowerInvariant(); + if (action is not ("read" or "query" or "write" or "manage")) + { + throw new CommandException("can-i", MessageService.GetArgsString("command-can-i-invalid-action", "action", this.Action ?? string.Empty)); + } + + commandState.SetFormat(this.OutputFormat); + + string? databaseName = this.Database; + string? containerName = this.Container; + switch (connectedState) + { + case ContainerState containerState: + databaseName ??= containerState.DatabaseName; + containerName ??= containerState.ContainerName; + break; + case DatabaseState databaseState: + databaseName ??= databaseState.DatabaseName; + break; + } + + // 'manage' maps to DDL / control-plane actions that cannot be probed without a + // mutating or control-plane operation, so it is always reported as indeterminate. + if (action == "manage") + { + return this.Build(commandState, action, databaseName, containerName, "indeterminate", "none", null, MessageService.GetString("command-can-i-manage-note")); + } + + if (string.IsNullOrEmpty(databaseName) || string.IsNullOrEmpty(containerName)) + { + throw new CommandException("can-i", MessageService.GetString("command-can-i-requires-container")); + } + + // Account-key and emulator connections use a master key, which grants full access. + if (shell.ActiveCredential is null) + { + return this.Build(commandState, action, databaseName, containerName, "allow", "key", null, MessageService.GetString("command-can-i-key-note")); + } + + var container = connectedState.Client.GetContainer(databaseName, containerName); + + HttpStatusCode statusCode; + switch (action) + { + case "read": + statusCode = await ProbeReadAsync(container, token); + break; + case "query": + statusCode = await ProbeQueryAsync(container, token); + break; + default: + statusCode = await ProbeWriteAsync(container, token); + break; + } + + var (decision, statusNote) = MapDecision(statusCode); + string? note = statusNote; + if (action == "query" && statusCode == HttpStatusCode.NotFound) + { + // For a query, an authorized caller against an empty container still gets 200 OK; + // a 404 means the target database or container does not exist, so query access + // cannot be inferred from it. + decision = "indeterminate"; + note = MessageService.GetString("command-can-i-query-notfound-note"); + } + else if (action == "write" && decision == "allow") + { + note = MessageService.GetString("command-can-i-write-heuristic-note"); + } + + return this.Build(commandState, action, databaseName, containerName, decision, "probe", (int)statusCode, note); + } + + private static async Task ProbeReadAsync(Container container, CancellationToken token) + { + using var response = await container.ReadItemStreamAsync( + Guid.NewGuid().ToString(), + new PartitionKey(Guid.NewGuid().ToString()), + requestOptions: null, + cancellationToken: token); + return response.StatusCode; + } + + private static async Task ProbeQueryAsync(Container container, CancellationToken token) + { + // A minimal TOP 1 query with a single-item page proves query authorization without + // forcing a full scan (as an aggregate like COUNT would) on large containers. + var requestOptions = new QueryRequestOptions { MaxItemCount = 1 }; + using var iterator = container.GetItemQueryStreamIterator( + new QueryDefinition("SELECT TOP 1 c.id FROM c"), + requestOptions: requestOptions); + using var response = await iterator.ReadNextAsync(token); + return response.StatusCode; + } + + private static async Task ProbeWriteAsync(Container container, CancellationToken token) + { + // Deleting a random, almost-certainly-nonexistent id is non-mutating: an authorized + // caller gets 404 NotFound, an unauthorized caller gets 403 Forbidden. The bogus + // If-Match ETag guarantees the probe never mutates data: even in the vanishingly + // unlikely event that the random id collides with an existing item, the delete fails + // with 412 PreconditionFailed (still treated as allow) instead of removing the item. + var requestOptions = new ItemRequestOptions { IfMatchEtag = "\"cosmosdb-shell-can-i-probe\"" }; + using var response = await container.DeleteItemStreamAsync( + Guid.NewGuid().ToString(), + new PartitionKey(Guid.NewGuid().ToString()), + requestOptions: requestOptions, + cancellationToken: token); + return response.StatusCode; + } + + private static (string Decision, string? Note) MapDecision(HttpStatusCode statusCode) + { + switch (statusCode) + { + case HttpStatusCode.Forbidden: + case HttpStatusCode.Unauthorized: + return ("deny", null); + case HttpStatusCode.OK: + case HttpStatusCode.NoContent: + case HttpStatusCode.NotFound: + case HttpStatusCode.PreconditionFailed: + return ("allow", null); + case HttpStatusCode.TooManyRequests: + return ("allow", MessageService.GetString("command-can-i-throttled-note")); + default: + return ("indeterminate", MessageService.GetArgsString("command-can-i-unexpected-status", "status", ((int)statusCode).ToString())); + } + } + + private static string BuildScope(string databaseName, string? containerName) + { + return string.IsNullOrEmpty(containerName) ? $"/{databaseName}" : $"/{databaseName}/{containerName}"; + } + + private CommandState Build( + CommandState commandState, + string action, + string? databaseName, + string? containerName, + string decision, + string method, + int? statusCode, + string? note) + { + var result = new Dictionary + { + ["action"] = action, + ["database"] = databaseName, + ["container"] = containerName, + ["decision"] = decision, + ["method"] = method, + ["statusCode"] = statusCode, + ["note"] = note, + }; + + commandState.RenderUser = () => this.RenderTable(action, databaseName, containerName, decision, method, statusCode, note); + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(result)); + return commandState; + } + + private void RenderTable(string action, string? databaseName, string? containerName, string decision, string method, int? statusCode, string? note) + { + AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-can-i-title"))); + + var table = new Table(); + table.AddColumns(string.Empty, string.Empty); + table.HideHeaders(); + + table.AddRow(MessageService.GetString("command-can-i-action"), Theme.FormatTableValue(action)); + if (!string.IsNullOrEmpty(databaseName)) + { + table.AddRow(MessageService.GetString("command-can-i-scope"), Theme.FormatTableValue(BuildScope(databaseName, containerName))); + } + + table.AddRow(MessageService.GetString("command-can-i-decision"), Theme.FormatTableValue(decision)); + table.AddRow(MessageService.GetString("command-can-i-method"), Theme.FormatTableValue(method)); + if (statusCode is { } code) + { + table.AddRow(MessageService.GetString("command-can-i-status"), Theme.FormatTableValue(code.ToString())); + } + + AnsiConsole.Write(table); + + if (!string.IsNullOrEmpty(note)) + { + AnsiConsole.MarkupLine(Theme.FormatMuted(note)); + } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs new file mode 100644 index 0000000..76b25f1 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs @@ -0,0 +1,162 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Commands; + +using System.Collections.Generic; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Mcp; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using global::Azure.Core; +using global::Azure.Identity; +using Spectre.Console; + +[CosmosCommand("whoami")] +[CosmosExample("whoami", Description = "Show the authenticated identity and credential type")] +[CosmosExample("whoami --format=json", Description = "Emit the identity as a JSON object")] +[CosmosExample("whoami --format=csv", Description = "Emit the identity as a single CSV row")] +[McpAnnotation( + Description = @" +Shows the authenticated identity for the current connection. + +Reports the credential type (for example DefaultAzureCredential, ManagedIdentityCredential, AccountKey, or Emulator) and, +for Entra ID connections, the principal, tenant, application id, and user principal name decoded from the access token. + +Data-plane RBAC role assignments are a control-plane concept and are not reported. Account-key and emulator connections have +no Entra identity, so only the credential type is shown. Use --format (table, json, csv) to control the output.")] +internal class WhoamiCommand : CosmosCommand +{ + private const string CosmosScope = "https://cosmos.azure.com/.default"; + + [CosmosOption("format", "f")] + public string? OutputFormat { get; init; } + + public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + if (shell.State is not ConnectedState) + { + throw new NotConnectedException("whoami"); + } + + commandState.SetFormat(this.OutputFormat); + + var credentialType = shell.ActiveCredentialType ?? "Unknown"; + var credential = shell.ActiveCredential; + + var result = new Dictionary + { + ["credentialType"] = credentialType, + }; + + if (credential is null) + { + // Account-key and emulator connections have no Entra identity to introspect. + var note = MessageService.GetString("command-whoami-key-auth-note"); + result["identityAvailable"] = false; + result["note"] = note; + commandState.RenderUser = () => RenderTable(credentialType, null, note); + } + else + { + AccessToken accessToken; + try + { + accessToken = await credential.GetTokenAsync(new TokenRequestContext(new[] { CosmosScope }), token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + throw; + } + catch (AuthenticationFailedException ex) + { + throw new CommandException("whoami", MessageService.GetArgsString("command-whoami-token-error", "message", ex.Message)); + } + + var claims = JwtClaims.TryDecodePayload(accessToken.Token); + var identity = BuildIdentity(claims, accessToken.ExpiresOn); + + result["identityAvailable"] = true; + foreach (var pair in identity) + { + result[pair.Key] = pair.Value; + } + + commandState.RenderUser = () => RenderTable(credentialType, identity, null); + } + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(result)); + return commandState; + } + + private static Dictionary BuildIdentity(JsonElement? claims, DateTimeOffset expiresOn) + { + var principalId = JwtClaims.GetString(claims, "oid"); + var tenantId = JwtClaims.GetString(claims, "tid"); + var applicationId = JwtClaims.GetString(claims, "appid", "azp"); + var userPrincipalName = JwtClaims.GetString(claims, "upn", "preferred_username", "unique_name"); + var displayName = JwtClaims.GetString(claims, "name"); + var identityTypeClaim = JwtClaims.GetString(claims, "idtyp"); + + string identityType = identityTypeClaim switch + { + "app" => "application", + "user" => "user", + _ => userPrincipalName is null ? "application" : "user", + }; + + return new Dictionary + { + ["principalId"] = principalId, + ["tenantId"] = tenantId, + ["applicationId"] = applicationId, + ["userPrincipalName"] = userPrincipalName, + ["displayName"] = displayName, + ["identityType"] = identityType, + ["tokenExpiresOn"] = expiresOn.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"), + }; + } + + private static void RenderTable(string credentialType, Dictionary? identity, string? note) + { + AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-whoami-title"))); + + var table = new Table(); + table.AddColumns(string.Empty, string.Empty); + table.HideHeaders(); + + table.AddRow(MessageService.GetString("command-whoami-credential-type"), Theme.FormatTableValue(credentialType)); + + if (identity is not null) + { + AddRow(table, "command-whoami-principal-id", identity["principalId"]); + AddRow(table, "command-whoami-tenant-id", identity["tenantId"]); + AddRow(table, "command-whoami-application-id", identity["applicationId"]); + AddRow(table, "command-whoami-user-principal-name", identity["userPrincipalName"]); + AddRow(table, "command-whoami-display-name", identity["displayName"]); + AddRow(table, "command-whoami-identity-type", identity["identityType"]); + AddRow(table, "command-whoami-token-expires", identity["tokenExpiresOn"]); + } + + AnsiConsole.Write(table); + + if (!string.IsNullOrEmpty(note)) + { + AnsiConsole.MarkupLine(Theme.FormatMuted(note)); + } + } + + private static void AddRow(Table table, string labelKey, object? value) + { + var text = value as string; + if (string.IsNullOrEmpty(text)) + { + return; + } + + table.AddRow(MessageService.GetString(labelKey), Theme.FormatTableValue(text)); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index d6fac47..c4f38f3 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -48,6 +48,8 @@ public partial class ShellInterpreter : IDisposable private readonly HashSet diagnosticSecrets = new(StringComparer.Ordinal); + private TokenCredential? activeCredential; + private LineEditor? lineEditor; private CosmosShellPrompt? cosmosShellPrompt; @@ -135,6 +137,19 @@ internal static char CSVSeparator internal Dictionary Functions { get; } = []; + /// + /// Gets the token credential backing the current connection, or null when the + /// connection uses an account key or emulator credentials (no Entra identity available). + /// + internal TokenCredential? ActiveCredential => this.activeCredential; + + /// + /// Gets a label describing how the current connection authenticated (for example + /// DefaultAzureCredential, ManagedIdentityCredential, AccountKey, + /// or Emulator), or null when not connected. + /// + internal string? ActiveCredentialType { get; private set; } + internal string HistoryFile { get; private set; } internal string WelcomeMarkerFile => this.welcomeMarkerFile; @@ -976,7 +991,7 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu } WriteLine(MessageService.GetArgsString("command-connect-connected", "account", keyProps.Id)); - this.Connect(client); + this.Connect(client, credentialTypeOverride: isEmulator ? "Emulator" : "AccountKey"); return; } @@ -1043,7 +1058,7 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu } WriteLine(MessageService.GetArgsString("command-connect-connected", "account", tokenProps.Id)); - this.Connect(client); + this.Connect(client, credential: credential); return; } @@ -1209,7 +1224,7 @@ private async Task CompleteTokenConnectionAsync( var explicitlyRequested = IsArmContextExplicitlyRequested(subscriptionId, resourceGroupName); if (!explicitlyRequested) { - this.Connect(client); + this.Connect(client, credential: credential); WriteLine(MessageService.GetArgsString("command-connect-connected", "account", accountId)); ArmCosmosContext? discoveredArmContext; @@ -1231,7 +1246,7 @@ private async Task CompleteTokenConnectionAsync( } var armContext = await this.TryDiscoverArmContextAsync(credential, client.Endpoint, subscriptionId, resourceGroupName, authorityHostUri, token); - this.Connect(client, armContext); + this.Connect(client, armContext, credential); WriteLine(MessageService.GetArgsString("command-connect-connected", "account", accountId)); } @@ -1473,10 +1488,12 @@ private async Task CompleteTokenConnectionAndDisposeOnFailureAsync( /// /// Connects to a client & disposes old state. /// - internal void Connect(CosmosClient client, ArmCosmosContext? armContext = null) + internal void Connect(CosmosClient client, ArmCosmosContext? armContext = null, TokenCredential? credential = null, string? credentialTypeOverride = null) { this.State?.Dispose(); this.State = new ConnectedState(client, armContext); + this.activeCredential = credential; + this.ActiveCredentialType = credentialTypeOverride ?? credential?.GetType().Name; this.CurrentBatch = null; CosmosCompleteCommand.ClearDatabases(); CosmosCompleteCommand.ClearContainers(); @@ -1542,6 +1559,8 @@ internal void Disconnect() { this.State?.Dispose(); this.State = new DisconnectedState(); + this.activeCredential = null; + this.ActiveCredentialType = null; this.CurrentBatch = null; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/JwtClaims.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/JwtClaims.cs new file mode 100644 index 0000000..7d46bd0 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Util/JwtClaims.cs @@ -0,0 +1,91 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Util; + +using System.Text.Json; + +/// +/// Helpers for reading claims from a JWT access token without validating its signature. +/// Used for local identity introspection only (for example the whoami command); +/// the token is never trusted for authorization decisions. +/// +internal static class JwtClaims +{ + /// + /// Decodes the payload segment of a JWT and returns its claims as a JSON element. + /// Returns null when the token is not a well-formed JWT. + /// + /// The raw JWT access token. + /// The decoded payload claims, or null when decoding fails. + public static JsonElement? TryDecodePayload(string? token) + { + if (string.IsNullOrEmpty(token)) + { + return null; + } + + var parts = token.Split('.'); + if (parts.Length != 3) + { + return null; + } + + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + switch (payload.Length % 4) + { + case 2: payload += "=="; break; + case 3: payload += "="; break; + } + + byte[] jsonBytes; + try + { + jsonBytes = Convert.FromBase64String(payload); + } + catch (FormatException) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(jsonBytes); + return doc.RootElement.Clone(); + } + catch (JsonException) + { + return null; + } + } + + /// + /// Reads a string claim from decoded JWT payload claims, trying each candidate name in order. + /// + /// The decoded JWT payload, or null. + /// The claim names to try, in priority order. + /// The first matching non-empty string claim, or null. + public static string? GetString(JsonElement? claims, params string[] names) + { + if (claims is not { ValueKind: JsonValueKind.Object } element) + { + return null; + } + + foreach (var name in names) + { + if (element.TryGetProperty(name, out var value) && + value.ValueKind == JsonValueKind.String) + { + var text = value.GetString(); + if (!string.IsNullOrEmpty(text)) + { + return text; + } + } + } + + return null; + } +} diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 60f5a13..726c82f 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -938,6 +938,40 @@ command-version-mcp = MCP running on port { $mcp_port} command-version-mcp-off = MCP server is off. command-version-repo = Report issues at [link={ $url }]{ $url }[/] +command-whoami-description = Shows the authenticated identity and credential type for the current connection. +command-whoami-description-format = { command-query-description-format } +command-whoami-title = Identity +command-whoami-credential-type = Credential Type +command-whoami-principal-id = Principal Id +command-whoami-tenant-id = Tenant Id +command-whoami-application-id = Application Id +command-whoami-user-principal-name = User Principal Name +command-whoami-display-name = Display Name +command-whoami-identity-type = Identity Type +command-whoami-token-expires = Token Expires +command-whoami-key-auth-note = Connected with account-key or emulator credentials; no Entra identity is available. +command-whoami-token-error = Could not acquire an access token to determine identity: { $message } + +command-can-i-description = Probes whether the current identity can perform an action against a container without mutating data. +command-can-i-description-action = The action to probe: read, query, write, or manage. +command-can-i-description-database = The database to probe (defaults to the current database). +command-can-i-description-container = The container to probe (defaults to the current container). +command-can-i-description-format = { command-query-description-format } +command-can-i-title = Access Check +command-can-i-action = Action +command-can-i-scope = Scope +command-can-i-decision = Decision +command-can-i-method = Method +command-can-i-status = Status Code +command-can-i-invalid-action = Unknown action '{ $action }'. Use read, query, write, or manage. +command-can-i-requires-container = A container is required. Provide --database and --container, or run can-i from within a container scope. +command-can-i-manage-note = 'manage' cannot be probed on the data plane without a mutating or control-plane operation. +command-can-i-key-note = Connected with a master key (account-key or emulator); all data actions are permitted. +command-can-i-write-heuristic-note = Heuristic: write access is inferred from delete permission via a non-mutating probe. +command-can-i-throttled-note = Request was throttled (429); the action appears permitted. +command-can-i-unexpected-status = The probe returned an unexpected status ({ $status }); access could not be determined. +command-can-i-query-notfound-note = The query returned 404 Not Found; the target database or container may not exist, so query access could not be determined. + help-RequiredWord = Required. help-ErrorsHeadingText = ERROR(S): help-UsageHeadingText = USAGE: diff --git a/README.md b/README.md index 7746128..c315ca4 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A terminal-native shell for Azure Cosmos DB — navigate databases like a filesy - Connect via Entra ID, connection string, or Azure CLI/Developer tools - Navigate with `ls` and `cd` (Account -> Databases -> Containers -> Items) - Inspect the current location with `pwd` +- Inspect the connected identity with `whoami`, and probe data-plane access with `can-i` (both support `--format` table/json/csv) - Create, query, replace, patch, delete: `mkdb`, `mkcon`, `mkitem`, `query`, `replace`, `patch`, `rm` - Inspect a query's execution plan and index usage with `query "" --explain` - Atomic multi-operation transactions on a single partition key: `batch` diff --git a/docs/commands.md b/docs/commands.md index 3e14a7e..bb9e909 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -34,6 +34,75 @@ Disconnect the current connection. Usage: disconnect ``` +## Diagnostics + +### whoami + +Show the authenticated identity and credential type for the current connection. + +For Microsoft Entra ID connections (interactive browser, device code, managed +identity, Visual Studio Code, static token, or `DefaultAzureCredential`) it +acquires a Cosmos DB access token and decodes the principal id, tenant id, +application id, user principal name, display name, and identity type from the +token claims. The token expiry is taken from the acquired token's metadata +(`ExpiresOn`) rather than the `exp` claim. The token signature is not validated; +the claims are used for local display only. + +Account-key and emulator connections use a master key and have no Entra +identity, so only the credential type is reported. + +```text +Usage: whoami [--format=] + +Options: + --format, -f Output format: table (default), json, or csv +``` + +Data-plane RBAC role assignments are a control-plane concept and are not +reported. The default interactive report is rendered as a table; use +`--format json` or `--format csv` for machine-readable output. Redirected output +is written in the selected format (JSON by default, or `table`/`csv` when +`COSMOSDB_SHELL_FORMAT` or `--format` selects them). The +`COSMOSDB_SHELL_FORMAT` environment variable sets the default format. This +command is read-only. + +### can-i + +Probe whether the current identity can perform an action against a container +without mutating data. + +```text +Usage: can-i [--database=] [--container=] [--format=] + +Arguments: + The action to probe: read, query, write, or manage + +Options: + --database, -db Target database name (defaults to the current database) + --container, -con Target container name (defaults to the current container) + --format, -f Output format: table (default), json, or csv +``` + +The probe issues a safe, non-mutating data-plane request and reports `allow`, +`deny`, or `indeterminate` based on the response: + +- `read` — a point read of a random id. +- `query` — a minimal `SELECT TOP 1` query with a page size of 1. +- `write` — a delete of a random, almost-certainly-nonexistent id (non-mutating). + A `deny` means no delete permission; an `allow` is a heuristic inference that + write access is present. +- `manage` — cannot be probed on the data plane without a mutating or + control-plane operation, so it is always reported as `indeterminate`. + +Account-key and emulator connections use a master key and are reported as +`allow` with method `key`. Entra connections use method `probe` and include the +HTTP status code observed. The default interactive report is rendered as a +table; use `--format json` or `--format csv` for machine-readable output. +Redirected output is written in the selected format (JSON by default, or +`table`/`csv` when `COSMOSDB_SHELL_FORMAT` or `--format` selects them). The +`COSMOSDB_SHELL_FORMAT` environment variable sets the default format. This +command does not mutate data. + ## Navigation ### ls