From 4bb02a7c098012363ea13e06696ecc90b3272922 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 04:30:35 -0300 Subject: [PATCH] Add basic anonymous usage telemetry Modelled after the .NET SDK: https://learn.microsoft.com/en-us/dotnet/core/tools/telemetry. NOTE: we use a fully non-PII GUID as the "session id", so that no user data is ever used for any telemetry/logging, thus simplifying the GDPR requirements (no need to purge telemetry data which is quite expensive). --- docs/github.md | 16 +- src/Cli/Program.cs | 3 +- src/Cli/Properties/launchSettings.json | 2 +- src/Cli/readme.md | 5 +- src/Commands/App.cs | 46 +++++- src/Commands/Commands.csproj | 2 + src/Commands/Extensions.cs | 4 +- src/Commands/Properties/Resources.resx | 6 + src/Commands/RemoveCommand.cs | 2 +- src/Commands/SyncCommand.cs | 13 +- src/Commands/WelcomeCommand.cs | 8 + src/Core/ActivityTracer.cs | 8 + src/Core/Core.csproj | 3 +- src/Core/SponsorManifest.cs | 53 ++++--- src/Core/SponsorableManifest.cs | 25 ++- src/Core/SponsorsManager.cs | 27 +++- src/Directory.props | 1 + src/Tests/GitHubFixture.cs | 2 +- src/Tests/Misc.cs | 54 ++++++- src/Tests/Signing.cs | 4 +- src/Tests/SponsorableManifestTests.cs | 2 +- src/Web/ActivityTelemetryExtensions.cs | 149 ++++++++++++++++++ .../ApplicationVersionTelemetryInitializer.cs | 10 ++ src/Web/GitHubDeviceFlowAuthentication.cs | 1 - src/Web/Program.cs | 12 +- src/Web/SponsorLink.cs | 28 +++- 26 files changed, 417 insertions(+), 69 deletions(-) create mode 100644 src/Core/ActivityTracer.cs create mode 100644 src/Web/ActivityTelemetryExtensions.cs create mode 100644 src/Web/ApplicationVersionTelemetryInitializer.cs diff --git a/docs/github.md b/docs/github.md index 8a708262..28bc8225 100644 --- a/docs/github.md +++ b/docs/github.md @@ -113,6 +113,16 @@ token from the standard input, which can be piped from a secure store or environ ``` +### Telemetry + +The `dotnet-sponsor` tool does not collect any telemetry by itself. Sponsor backend services may collect +anonymous usage telemetry to improve your experience, however. Such telemetry is associated by default +with an opaque and random identifier of the tool installation that is not linked to any personal information. + +Telemetry data helps the backend team understand how its APIs are used by the tool so they can be improved. +To opt out of associating the backend API invocations with your tool installation, set the +`SPONSOR_CLI_TELEMETRY_OPTOUT` environment variable to `1` or `true`. + ### Sponsoring Checks SponsorLink-enabled libraries and tools can use the previously synchronized sponsor manifest to check the @@ -245,9 +255,9 @@ To deploy and configure the backend: 1. [Create an Azure Functions app](https://portal.azure.com/#create/Microsoft.FunctionApp) 1. Setup deployment to the Azure Functions app from your forked repository 1. Configure the following application settings: - * `GitHub__Token`: a GitHub token with permissions to read the sponsorable profile, emails, sponsorships and repositories - * `SponsorLink__Account`: the sponsorable GitHub account name, unless it's the same as the GitHub token owner - * `SponsorLink__PrivateKey`: the Base64-encoded private key (the contents of `curl.key.txt` in the example above) + * `GitHub:Token`: a GitHub token with permissions to read the sponsorable profile, emails, sponsorships and repositories + * `SponsorLink:Account`: the sponsorable GitHub account name, unless it's the same as the GitHub token owner + * `SponsorLink:PrivateKey`: the Base64-encoded private key (the contents of `curl.key.txt` in the example above) Finally, enable the GitHub identity provider under Settings > Authentication, providing the OAuth app's Client ID and Client Secret. Make sure you set `Allow unauthenticated requests` and have `Token store` enabled. diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index 0c84898a..7a1810d4 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -1,5 +1,4 @@ -#pragma warning disable CS0436 // Type conflicts with imported type -using System.Diagnostics; +using System.Diagnostics; using Devlooped.Sponsors; using DotNetConfig; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Cli/Properties/launchSettings.json b/src/Cli/Properties/launchSettings.json index d5de63ce..10de7b7c 100644 --- a/src/Cli/Properties/launchSettings.json +++ b/src/Cli/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Cli": { "commandName": "Project", - "commandLineArgs": "remove devlooped" + "commandLineArgs": "sync devlooped --issuer https://donkey-emerging-civet.ngrok-free.app/ --force" } } } \ No newline at end of file diff --git a/src/Cli/readme.md b/src/Cli/readme.md index b1ed5239..33a5797b 100644 --- a/src/Cli/readme.md +++ b/src/Cli/readme.md @@ -41,4 +41,7 @@ COMMANDS: remove Removes all manifests and notifies issuers to remove backend data too sync Synchronizes sponsorship manifests view Validates and displays the active sponsor manifests, if any -``` \ No newline at end of file +``` + +Learn more [about the tool](https://github.com/devlooped/SponsorLink/blob/main/docs/github.md#sponsor-manifest-sync) +and related [telemetry](https://github.com/devlooped/SponsorLink/blob/main/docs/github.md#telemetry). \ No newline at end of file diff --git a/src/Commands/App.cs b/src/Commands/App.cs index 93516a9d..51cd1827 100644 --- a/src/Commands/App.cs +++ b/src/Commands/App.cs @@ -12,15 +12,28 @@ public static class App public static CommandApp Create(out IServiceProvider services) { var collection = new ServiceCollection(); + var sldir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink"); + Directory.CreateDirectory(sldir); - // Made transient so each command gets a new copy with potentially updated values. - collection.AddTransient(sp => + var config = Config.Build(sldir); + // Auto-initialize an opaque instance/installation id + if (!config.TryGetString("sponsorlink", "id", out var id)) + { + id = Guid.NewGuid().ToString(); + config = config.SetString("sponsorlink", "id", id); + } + + // Don't propagate traceparent from client (we don't collect telemetry to correlate). + DistributedContextPropagator.Current = DistributedContextPropagator.CreateNoOutputPropagator(); + ActivitySource.AddActivityListener(new ActivityListener { - var sldir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink"); - Directory.CreateDirectory(sldir); - return Config.Build(sldir); + // Forces our activities to be created, thereby being available to pull into headers + ShouldListenTo = activity => activity.Name.StartsWith("Devlooped"), + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.PropagationData, }); + // Made transient so each command gets a new copy with potentially updated values. + collection.AddTransient(sp => Config.Build(sldir)); collection.AddSingleton(new CliGraphQueryClient()); collection.AddSingleton(sp => new GitHubAppAuthenticator(sp.GetRequiredService())); collection.AddHttpClient().ConfigureHttpClientDefaults(defaults => defaults.ConfigureHttpClient(http => @@ -28,6 +41,13 @@ public static CommandApp Create(out IServiceProvider services) http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ThisAssembly.Info.Product, ThisAssembly.Info.InformationalVersion)); if (Debugger.IsAttached) http.Timeout = TimeSpan.FromMinutes(10); + + var optout = Environment.GetEnvironmentVariable("SPONSOR_CLI_TELEMETRY_OPTOUT"); + if (optout == null || (optout != "1" && optout != "true")) + http.DefaultRequestHeaders.TryAddWithoutValidation("x-telemetry-id", id); + + if (Activity.Current is { } activity) + http.DefaultRequestHeaders.TryAddWithoutValidation("x-telemetry-operation", activity.OperationName); })); collection.AddHttpClient("GitHub", http => { @@ -38,6 +58,8 @@ public static CommandApp Create(out IServiceProvider services) AllowAutoRedirect = false, }); + collection.AddTransient(); + var registrar = new TypeRegistrar(collection); var app = new CommandApp(registrar); registrar.Services.AddSingleton(app); @@ -57,4 +79,18 @@ public static CommandApp Create(out IServiceProvider services) return app; } + + class ActivityCommandInterceptor : ICommandInterceptor + { + Activity? activity; + + public void Intercept(CommandContext context, CommandSettings settings) + => activity = ActivityTracer.Source.StartActivity(context.Name, ActivityKind.Client); + + public void InterceptResult(CommandContext context, CommandSettings settings, ref int result) + { + activity?.SetTag("ExitCode", result); + activity?.Dispose(); + } + } } diff --git a/src/Commands/Commands.csproj b/src/Commands/Commands.csproj index 58b7b743..0d571942 100644 --- a/src/Commands/Commands.csproj +++ b/src/Commands/Commands.csproj @@ -19,6 +19,8 @@ + + diff --git a/src/Commands/Extensions.cs b/src/Commands/Extensions.cs index 32031a3f..f6a6d888 100644 --- a/src/Commands/Extensions.cs +++ b/src/Commands/Extensions.cs @@ -29,8 +29,8 @@ public static IRenderable ToDetails(this JwtSecurityToken jwt, string path) var header = $"|:magnifying_glass_tilted_left:[link={path}]~{Path.DirectorySeparatorChar}.sponsorlink{path[root.Length..]}[/] |"; var content = new List - { - new JsonText(jwt.Payload.SerializeToJson()) + { + new JsonText(jwt.Payload.SerializeToJson()) }; if (jwt.Claims.Where(c => c.Type == "client_id").Select(c => c.Value).FirstOrDefault() is string client_id) diff --git a/src/Commands/Properties/Resources.resx b/src/Commands/Properties/Resources.resx index bebf0355..81957b3c 100644 --- a/src/Commands/Properties/Resources.resx +++ b/src/Commands/Properties/Resources.resx @@ -332,4 +332,10 @@ Code for the backend and this app are [link=https://www.devlooped.com/SponsorLin :check_mark_button: [lime]{sponsorable}[/]: manifest expires [yellow]{date}[/] [dim](roles: {roles})[/] + + Each sponsor issuer backend may collect anonymous usage data telemetry to improve your experience. To send your preference to not get such telemetry collected, you can set a SPONSOR_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your favorite shell. You can read more about telemetry [link=https://www.devlooped.com/SponsorLink/github.html#telemetry]in the docs[/]. + + + Telemetry + \ No newline at end of file diff --git a/src/Commands/RemoveCommand.cs b/src/Commands/RemoveCommand.cs index 2172af6e..585a2ed2 100644 --- a/src/Commands/RemoveCommand.cs +++ b/src/Commands/RemoveCommand.cs @@ -85,7 +85,7 @@ await Status().StartAsync(Remove.Removing(sponsorables.First()), async ctx => File.Delete(file); MarkupLine(Remove.Done(sponsorable)); - + var padding = new Padding(3, 0, 0, 0); Write(new Padder(new Markup(Remove.DeletedManifest(Path.Combine("~", ".sponsorlink", "github", sponsorable + ".jwt"))), padding)); diff --git a/src/Commands/SyncCommand.cs b/src/Commands/SyncCommand.cs index 7117ecc0..71974ba6 100644 --- a/src/Commands/SyncCommand.cs +++ b/src/Commands/SyncCommand.cs @@ -255,7 +255,8 @@ await Status().StartAsync(Sync.FetchingManifests(sponsorables.Count), async ctx { // We can directly query via the default HTTP client since the .github repository must be public. branch = await httpFactory.GetQueryClient().QueryAsync(new GraphQuery( - $"/repos/{sponsorable}/.github", ".default_branch") { IsLegacy = true }); + $"/repos/{sponsorable}/.github", ".default_branch") + { IsLegacy = true }); if (branch != null && branch != "main") // Retry discovery with non-'main' branch @@ -265,6 +266,10 @@ await Status().StartAsync(Sync.FetchingManifests(sponsorables.Count), async ctx switch (status) { case SponsorableManifest.Status.OK when manifest != null: + // Mostly for testing purposes, we can override the issuer. + if (settings.Issuer != null) + manifest.Issuer = settings.Issuer; + manifests.Add(manifest); break; case SponsorableManifest.Status.NotFound: @@ -302,7 +307,8 @@ await Status().StartAsync(Sync.FetchingManifests(sponsorables.Count), async ctx continue; } - var (status, jwt) = await Status().StartAsync(Sync.Synchronizing(manifest.Sponsorable), async ctx => await SponsorManifest.FetchAsync(manifest, token)); + using var http = httpFactory.CreateClient(); + var (status, jwt) = await Status().StartAsync(Sync.Synchronizing(manifest.Sponsorable), async ctx => await SponsorManifest.FetchAsync(manifest, token, http)); if (status == SponsorManifest.Status.NotSponsoring) { var links = string.Join(", ", manifest.Audience.Select(x => $"[link]{x}[/]")); @@ -343,7 +349,8 @@ await Status().StartAsync(Sync.Synchronizing(manifest.Sponsorable), async ctx => if (string.IsNullOrEmpty(token)) return; - var (status, jwt) = await SponsorManifest.FetchAsync(manifest, token); + using var http = httpFactory.CreateClient(); + var (status, jwt) = await SponsorManifest.FetchAsync(manifest, token, http); if (status == SponsorManifest.Status.NotSponsoring) { var links = string.Join(", ", manifest.Audience.Select(x => $"[link]{x}[/]")); diff --git a/src/Commands/WelcomeCommand.cs b/src/Commands/WelcomeCommand.cs index ea3e3ebd..81cff4bb 100644 --- a/src/Commands/WelcomeCommand.cs +++ b/src/Commands/WelcomeCommand.cs @@ -46,6 +46,14 @@ public override int Execute(CommandContext context, WelcomeSettings settings) Padding = new Padding(2, 1, 2, 0), }); + AnsiConsole.Write(new Panel(new Rows( + new Rule(ThisAssembly.Strings.FirstRun.TelemetryTitle).RuleStyle(Color.MediumPurple2).LeftJustified(), + new Markup(ThisAssembly.Strings.FirstRun.Telemetry))) + { + Border = BoxBorder.None, + Padding = new Padding(2, 1, 2, 0), + }); + if (!AnsiConsole.Confirm(ThisAssembly.Strings.FirstRun.Acceptance)) { return -1; diff --git a/src/Core/ActivityTracer.cs b/src/Core/ActivityTracer.cs new file mode 100644 index 00000000..760b6091 --- /dev/null +++ b/src/Core/ActivityTracer.cs @@ -0,0 +1,8 @@ +using System.Diagnostics; + +namespace Devlooped.Sponsors; + +public static class ActivityTracer +{ + public static ActivitySource Source { get; } = new("Devlooped.Sponsors", ThisAssembly.Info.InformationalVersion); +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 366a1dff..786684c0 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -16,11 +16,12 @@ + - + diff --git a/src/Core/SponsorManifest.cs b/src/Core/SponsorManifest.cs index fc4ac1f4..a5fe873c 100644 --- a/src/Core/SponsorManifest.cs +++ b/src/Core/SponsorManifest.cs @@ -34,38 +34,45 @@ public enum Status /// The SponsorLink manifest provided by the sponsorable account. /// The sponsor manifest token, if sponsoring. /// The status of the manifest synchronization. - public static async Task<(Status, string?)> FetchAsync(SponsorableManifest manifest, string accessToken) + public static async Task<(Status, string?)> FetchAsync(SponsorableManifest manifest, string accessToken, HttpClient? http = default) { - using var http = new HttpClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(manifest.Issuer), "me")); - request.Headers.Authorization = new("Bearer", accessToken); - request.Headers.Accept.Add(new("application/jwt")); - var response = await http.SendAsync(request); + var disposeHttp = http == null; + try + { + var request = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(manifest.Issuer), "me")); + request.Headers.Authorization = new("Bearer", accessToken); + request.Headers.Accept.Add(new("application/jwt")); + var response = await (http ??= new HttpClient()).SendAsync(request); - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - return (Status.NotSponsoring, default); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + return (Status.NotSponsoring, default); - if (!response.IsSuccessStatusCode) - return (Status.SyncFailure, default); + if (!response.IsSuccessStatusCode) + return (Status.SyncFailure, default); - var jwt = await response.Content.ReadAsStringAsync(); - if (string.IsNullOrEmpty(jwt)) - return (Status.SyncFailure, default); + var jwt = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrEmpty(jwt)) + return (Status.SyncFailure, default); - if (new JwtSecurityTokenHandler().CanReadToken(jwt) == false) - return (Status.SyncFailure, default); + if (new JwtSecurityTokenHandler().CanReadToken(jwt) == false) + return (Status.SyncFailure, default); - try - { - // verify no tampering with the manifest - var claims = manifest.Validate(jwt, out var sectoken); + try + { + // verify no tampering with the manifest + var claims = manifest.Validate(jwt, out var sectoken); - return (Status.Success, jwt); + return (Status.Success, jwt); + } + catch (SecurityTokenException) + { + return (Status.SyncFailure, default); + } } - catch (SecurityTokenException) + finally { - return (Status.SyncFailure, default); + if (disposeHttp) + http?.Dispose(); } } } diff --git a/src/Core/SponsorableManifest.cs b/src/Core/SponsorableManifest.cs index a775506c..23873a43 100644 --- a/src/Core/SponsorableManifest.cs +++ b/src/Core/SponsorableManifest.cs @@ -50,7 +50,7 @@ public static SponsorableManifest Create(Uri issuer, Uri[] audience, string clie // Try to detect sponsorlink manifest in the sponsorable .github repo var url = $"https://github.com/{sponsorable}/.github/raw/{branch ?? "main"}/sponsorlink.jwt"; var disposeHttp = http == null; - + // Manifest should be public, so no need for any special HTTP client. try { @@ -120,11 +120,12 @@ public static bool TryRead(string jwt, [NotNullWhen(true)] out SponsorableManife int hashcode; string clientId; + string issuer; public SponsorableManifest(Uri issuer, Uri[] audience, string clientId, SecurityKey publicKey) { this.clientId = clientId; - Issuer = issuer.AbsoluteUri; + this.issuer = issuer.AbsoluteUri; Audience = audience.Select(a => a.AbsoluteUri.TrimEnd('/')).ToArray(); SecurityKey = publicKey; Sponsorable = audience.Where(x => x.Host == "github.com").Select(x => x.Segments.LastOrDefault()?.TrimEnd('/')).FirstOrDefault() ?? @@ -254,7 +255,12 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim RequireAudience = true, // At least one of the audiences must match the manifest audiences AudienceValidator = (audiences, _, _) => Audience.Intersect(audiences.Select(x => x.TrimEnd('/'))).Any(), + // We don't validate the issuer in debug builds, to allow testing with localhost-run backend. +#if DEBUG + ValidateIssuer = false, +#else ValidIssuer = Issuer, +#endif IssuerSigningKey = SecurityKey, }, out token); @@ -269,7 +275,16 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim /// /// See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1 /// - public string Issuer { get; } + public string Issuer + { + get => issuer; + internal set + { + issuer = value; + var thumb = JsonWebKeyConverter.ConvertFromSecurityKey(SecurityKey).ComputeJwkThumbprint(); + hashcode = new HashCode().Add(Issuer, ClientId, Convert.ToBase64String(thumb)).AddRange(Audience).ToHashCode(); + } + } /// /// The audience for the JWT, which includes the sponsorable account and potentially other sponsoring platforms. @@ -286,8 +301,8 @@ public string Sign(IEnumerable claims, RsaSecurityKey? key = default, Tim /// /// See https://www.rfc-editor.org/rfc/rfc8693.html#name-client_id-client-identifier /// - public string ClientId - { + public string ClientId + { get => clientId; internal set { diff --git a/src/Core/SponsorsManager.cs b/src/Core/SponsorsManager.cs index 0b4c12b1..a2e70b70 100644 --- a/src/Core/SponsorsManager.cs +++ b/src/Core/SponsorsManager.cs @@ -1,4 +1,5 @@ -using System.IdentityModel.Tokens.Jwt; +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text.Json; using System.Text.RegularExpressions; @@ -62,6 +63,9 @@ await client.QueryAsync(GraphQueries.FindOrganization(options.Account)) { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) }); + + Activity.Current?.AddEvent(new ActivityEvent("Sponsorable.GotManifest", + tags: new ActivityTagsCollection([KeyValuePair.Create("sponsorable", manifest.Sponsorable)]))); } return jwt; @@ -200,11 +204,19 @@ await sponsorable.QueryAsync(GraphQueries.SponsoringOrganizationsForUser(account public async Task?> GetSponsorClaimsAsync(ClaimsPrincipal? principal = default) { principal ??= ClaimsPrincipal.Current; - var manifest = await GetManifestAsync(); + if (principal is not { Identity.IsAuthenticated: true }) + return null; + var manifest = await GetManifestAsync(); var sponsor = await GetSponsorTypeAsync(principal); - if (sponsor == SponsorTypes.None || - principal?.FindFirst("urn:github:login")?.Value is not string login) + + if (sponsor == SponsorTypes.None) + { + Activity.Current?.AddEvent(new ActivityEvent("Sponsor.NotSponsoring")); + return null; + } + + if (principal?.FindFirst("urn:github:login")?.Value is not string login) return null; var claims = new List @@ -231,6 +243,13 @@ await sponsorable.QueryAsync(GraphQueries.SponsoringOrganizationsForUser(account // Use shorthand JWT claim for emails. See https://www.iana.org/assignments/jwt/jwt.xhtml claims.AddRange(principal.Claims.Where(x => x.Type == ClaimTypes.Email).Select(x => new Claim(JwtRegisteredClaimNames.Email, x.Value))); + Activity.Current?.AddEvent(new ActivityEvent("Sponsor.GotClaims", + tags: new ActivityTagsCollection( + [ + // NOTE: this is NOT PII since they are generic role kinds + KeyValuePair.Create("roles", string.Join(',', claims.Where(x => x.Type == "roles").Select(x => x.Value))) + ]))); + return claims; } diff --git a/src/Directory.props b/src/Directory.props index 43ff4ad2..bf56d4bc 100644 --- a/src/Directory.props +++ b/src/Directory.props @@ -10,6 +10,7 @@ false Preview true + CS0436;$(NoWarn) diff --git a/src/Tests/GitHubFixture.cs b/src/Tests/GitHubFixture.cs index 8d409d68..92ee4ca2 100644 --- a/src/Tests/GitHubFixture.cs +++ b/src/Tests/GitHubFixture.cs @@ -28,7 +28,7 @@ public GitHubFixture() public void Dispose() { if (existingToken != null && - TryExecute("gh", "auth token", out var currentToken) && + TryExecute("gh", "auth token", out var currentToken) && existingToken != currentToken) { Assert.True(TryExecute("gh", $"auth login --with-token", existingToken, out _)); diff --git a/src/Tests/Misc.cs b/src/Tests/Misc.cs index c0ae1b54..435695ad 100644 --- a/src/Tests/Misc.cs +++ b/src/Tests/Misc.cs @@ -1,16 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Azure.Core; +using Azure.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Spectre.Console; +using static Devlooped.Helpers; namespace Devlooped.Sponsors; -public class Misc +public class Misc(ITestOutputHelper output) { public record TypedConfig { @@ -47,6 +47,48 @@ public void WriteIni() Assert.True(typed.Auto); } + [SecretsFact("Azure:SubscriptionId", "Azure:ResourceGroup", "Azure:LogAnalytics")] + public async Task GetPurgeStatus() + { + // Purge endpoint is https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.OperationalInsights/workspaces/{workspace}/purge?api-version=2020-08-01 + // See https://learn.microsoft.com/en-us/rest/api/loganalytics/workspace-purge/purge?view=rest-loganalytics-2023-09-01&tabs=HTTP + var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions + { + ExcludeManagedIdentityCredential = true, + ExcludeSharedTokenCacheCredential = true, + ExcludeVisualStudioCodeCredential = true, + ExcludeVisualStudioCredential = true, + ExcludeEnvironmentCredential = true, + ExcludeInteractiveBrowserCredential = true, + TenantId = Configuration["Azure:SubscriptionId"] + }); + + var token = await credential.GetTokenAsync(new TokenRequestContext(["https://management.azure.com/.default"])); + + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + // Sample ID + var purgeId = "purge-6a89f2a1-baeb-4722-845f-51f33dcc4c2c"; + + // Get status, see https://learn.microsoft.com/en-us/rest/api/loganalytics/workspace-purge/get-purge-status?view=rest-loganalytics-2023-09-01&tabs=HTTP + var url = $"https://management.azure.com/subscriptions/{Configuration["Azure:SubscriptionId"]}/resourceGroups/{Configuration["Azure:ResourceGroup"]}/providers/Microsoft.OperationalInsights/workspaces/{Configuration["Azure:LogAnalytics"]}/operations/{purgeId}?api-version=2020-08-01"; + + var response = await httpClient.GetAsync(url); + var content = await response.Content.ReadAsStringAsync(); + if (response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadFromJsonAsync(); + output.WriteLine(responseBody?.status); + } + else + { + output.WriteLine($"Request failed with status code {response.StatusCode}"); + } + } + + record PurgeStatus(string status); + public static void SponsorsAscii() { var heart = diff --git a/src/Tests/Signing.cs b/src/Tests/Signing.cs index a80152b2..67c91b63 100644 --- a/src/Tests/Signing.cs +++ b/src/Tests/Signing.cs @@ -154,7 +154,7 @@ public void JwtSponsorableManifest() // NOTE: we cannot recreate the RSAPublicKey from the JWK, so we can never // compare the raw string, but we can still validate with either one. - var principal1 = new JwtSecurityTokenHandler { MapInboundClaims = false }.ValidateToken(jwt, + var principal1 = new JwtSecurityTokenHandler { MapInboundClaims = false }.ValidateToken(jwt, new TokenValidationParameters { RequireExpirationTime = false, @@ -166,7 +166,7 @@ public void JwtSponsorableManifest() var pubRsa = RSA.Create(); pubRsa.ImportRSAPublicKey(rsa.ExportRSAPublicKey(), out _); - var principal2 = new JwtSecurityTokenHandler { MapInboundClaims = false }.ValidateToken(jwt, + var principal2 = new JwtSecurityTokenHandler { MapInboundClaims = false }.ValidateToken(jwt, new TokenValidationParameters { RequireExpirationTime = false, diff --git a/src/Tests/SponsorableManifestTests.cs b/src/Tests/SponsorableManifestTests.cs index 17038be0..7a6efad3 100644 --- a/src/Tests/SponsorableManifestTests.cs +++ b/src/Tests/SponsorableManifestTests.cs @@ -18,7 +18,7 @@ public void CanCreateManifest() var jwt = manifest.ToJwt(); // Ensures token is signed - var principal = new JwtSecurityTokenHandler { MapInboundClaims = false }.ValidateToken(jwt, new TokenValidationParameters + var principal = new JwtSecurityTokenHandler { MapInboundClaims = false }.ValidateToken(jwt, new TokenValidationParameters { RequireExpirationTime = false, ValidateAudience = true, diff --git a/src/Web/ActivityTelemetryExtensions.cs b/src/Web/ActivityTelemetryExtensions.cs new file mode 100644 index 00000000..32745319 --- /dev/null +++ b/src/Web/ActivityTelemetryExtensions.cs @@ -0,0 +1,149 @@ +using System.Diagnostics; +using System.Security.Claims; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Middleware; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Devlooped.Sponsors; + +/// +/// Sets the to a function +/// that accesses the current by leveraging the +/// to retrieve the principal from +/// the , if present. +/// +public static partial class ActivityTelemetryExtensions +{ + static readonly HashSet skipProps = ["faas.execution", "az.schema_url"]; + + /// + /// Ensures that accesses the principal + /// authenticated by the app service authentication middleware. + /// + public static IFunctionsWorkerApplicationBuilder UseActivityTelemetry(this IFunctionsWorkerApplicationBuilder builder) + { + builder.UseMiddleware(); + return builder; + } + + public static IServiceCollection AddActivityTelemetry(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + class Module(Lazy telemetry) : ITelemetryModule, IDisposable + { + ActivityListener? listener; + + public void Initialize(TelemetryConfiguration configuration) + { + listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + // Basically detects if we have an authenticated user, which are the + // only type of events we track for now. + if (!telemetry.IsValueCreated) + return; + + foreach (var ev in activity.Events) + { + var et = new EventTelemetry(ev.Name) + { + Timestamp = ev.Timestamp + }; + + foreach (var item in activity.Baggage.Where(x => !skipProps.Contains(x.Key))) + et.Properties[item.Key] = item.Value; + + foreach (var item in activity.Tags.Where(x => !skipProps.Contains(x.Key))) + et.Properties[item.Key] = item.Value?.ToString() ?? ""; + + foreach (var item in ev.Tags.Where(x => !skipProps.Contains(x.Key))) + et.Properties[item.Key] = item.Value?.ToString() ?? ""; + + telemetry.Value.TrackEvent(et); + } +#if DEBUG + // Makes it easier to inspect telemetry in debug mode. + telemetry.Value.Flush(); +#endif + }, + }; + + ActivitySource.AddActivityListener(listener); + } + + public void Dispose() + { + listener?.Dispose(); + telemetry.Value.Flush(); + } + } + + class Middleware(Lazy telemetry) : IFunctionsWorkerMiddleware + { + public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) + { + var request = await context.GetHttpRequestDataAsync(); + if (request?.Headers.TryGetValues("x-telemetry-id", out var ids) == true && + ids.FirstOrDefault() is { Length: > 0 } id) + { + // Associate with opaque installation id + Activity.Current?.AddBaggage("session_Id", id); + telemetry.Value.Context.Session.Id = id; + } + + if (request?.Headers.TryGetValues("x-telemetry-operation", out var ops) == true && + ops.FirstOrDefault() is { Length: > 0 } op) + { + // Associate with opaque installation id + Activity.Current?.AddBaggage("operation_Name", op); + telemetry.Value.Context.Operation.Name = op; + } + + await next(context); + } + } + + class Initializer : ITelemetryInitializer + { + public void Initialize(ITelemetry telemetry) + { + // We don't set the user id for metrics, as they are not tied to a user. + if (telemetry is MetricTelemetry) + return; + + if (telemetry is ISupportProperties sprops && + sprops.Properties.TryGetValue("session_Id", out var propId)) + { + telemetry.Context.Session.Id = propId; + sprops.Properties.Remove("session_Id"); + } + else if (Activity.Current?.GetBaggageItem("session_Id") is { } baggageId) + { + telemetry.Context.Session.Id = baggageId; + } + + if (telemetry is ISupportProperties opprops && + opprops.Properties.TryGetValue("operation_Name", out var propName)) + { + telemetry.Context.Operation.Name = propName; + opprops.Properties.Remove("operation_Name"); + } + else if (Activity.Current?.GetBaggageItem("operation_Name") is { } baggageName) + { + telemetry.Context.Operation.Name = baggageName; + } + } + } +} diff --git a/src/Web/ApplicationVersionTelemetryInitializer.cs b/src/Web/ApplicationVersionTelemetryInitializer.cs new file mode 100644 index 00000000..be94e8fc --- /dev/null +++ b/src/Web/ApplicationVersionTelemetryInitializer.cs @@ -0,0 +1,10 @@ +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; + +namespace Devlooped.Sponsors; + +public class ApplicationVersionTelemetryInitializer : ITelemetryInitializer +{ + public void Initialize(ITelemetry telemetry) => + telemetry.Context.Component.Version = ThisAssembly.Info.InformationalVersion; +} diff --git a/src/Web/GitHubDeviceFlowAuthentication.cs b/src/Web/GitHubDeviceFlowAuthentication.cs index 1f215130..6efde764 100644 --- a/src/Web/GitHubDeviceFlowAuthentication.cs +++ b/src/Web/GitHubDeviceFlowAuthentication.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; -using System.Reflection; using System.Security.Claims; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/src/Web/Program.cs b/src/Web/Program.cs index ff12aa0a..7bc01b16 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,9 +1,10 @@ -using System.Diagnostics; using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; using System.Security.Cryptography; using Azure.Identity; using Devlooped.Sponsors; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -32,14 +33,21 @@ static void Main(string[] args) builder.UseAppServiceAuthentication(); builder.UseGitHubAuthentication(populateEmails: true, verifiedOnly: true); builder.UseClaimsPrincipal(); + builder.UseActivityTelemetry(); }) .ConfigureServices(services => { + // Register first so it initializes always before every other initializer. + services.AddSingleton(); + services.AddApplicationInsightsTelemetryWorkerService(); services.ConfigureFunctionsApplicationInsights(); - services.AddMemoryCache(); + services.AddActivityTelemetry(); + services.AddMemoryCache(); services.AddOptions(); + services.AddSingleton, Lazy>(sp => new(() => sp.GetRequiredService())); + JwtSecurityTokenHandler.DefaultMapInboundClaims = false; services diff --git a/src/Web/SponsorLink.cs b/src/Web/SponsorLink.cs index f9c12118..00c5bee7 100644 --- a/src/Web/SponsorLink.cs +++ b/src/Web/SponsorLink.cs @@ -1,14 +1,20 @@ -using System.IdentityModel.Tokens.Jwt; +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; +using Azure.Core; +using Azure.Identity; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -21,6 +27,8 @@ namespace Devlooped.Sponsors; /// partial class SponsorLink(IConfiguration configuration, IHttpClientFactory httpFactory, SponsorsManager sponsors, RSA rsa, IWebHostEnvironment host, ILogger logger) { + static ActivitySource tracer = new("Devlooped.Sponsors"); + static readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; /// @@ -29,6 +37,8 @@ partial class SponsorLink(IConfiguration configuration, IHttpClientFactory httpF [Function("user")] public async Task UserAsync([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req) { + using var activity = tracer.StartActivity(); + if (!configuration.TryGetClientId(logger, out var clientId)) return new StatusCodeResult(500); @@ -86,6 +96,7 @@ public async Task UserAsync([HttpTrigger(AuthorizationLevel.Anony [Function("jwt")] public async Task GetManifest([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req) { + using var activity = tracer.StartActivity(); var manifest = await sponsors.GetManifestAsync(); if (!rsa.ThumbprintEquals(manifest.SecurityKey)) { @@ -121,6 +132,7 @@ public async Task GetManifest([HttpTrigger(AuthorizationLevel.Ano [Function("jwk")] public async Task GetPublicKey([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req) { + using var activity = tracer.StartActivity(); var manifest = await sponsors.GetManifestAsync(); return new ContentResult() @@ -137,6 +149,8 @@ public async Task GetPublicKey([HttpTrigger(AuthorizationLevel.An [Function("me")] public async Task SyncAsync([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "me")] HttpRequest req) { + using var activity = tracer.StartActivity(); + if (!configuration.TryGetClientId(logger, out var clientId)) return new StatusCodeResult(500); @@ -179,6 +193,7 @@ public async Task SyncAsync([HttpTrigger(AuthorizationLevel.Anony // We always respond authenticated requests either with a JWT or JSON, depending on the Accept header. if (req.Headers.Accept.Contains("application/jwt")) { + Activity.Current?.SetTag("ContentType", "jwt"); return new ContentResult { Content = jwt, @@ -188,6 +203,8 @@ public async Task SyncAsync([HttpTrigger(AuthorizationLevel.Anony } var token = new JwtSecurityTokenHandler().ReadJwtToken(jwt); + Activity.Current?.SetTag("ContentType", "json"); + return new ContentResult { Content = token.Payload.SerializeToJson(), @@ -199,14 +216,15 @@ public async Task SyncAsync([HttpTrigger(AuthorizationLevel.Anony [Function("delete")] public IActionResult Delete([HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "me")] HttpRequest req) { + using var activity = tracer.StartActivity(); + if (!configuration.TryGetClientId(logger, out _)) return new StatusCodeResult(500); - if (ClaimsPrincipal.Current is not { Identity.IsAuthenticated: true }) + if (ClaimsPrincipal.Current is not { Identity.IsAuthenticated: true } || + ClaimsPrincipal.Current.FindFirstValue(ClaimTypes.NameIdentifier) is not { } id) return new UnauthorizedResult(); - logger.LogInformation("We don't persist anything, so there's nothing to delete :)"); - - return new OkResult(); + return new ObjectResult("No personal data is persisted by this backend implementation 👍"); } } \ No newline at end of file