using System.Net.Http.Headers;
using System.Text.Json;
using Azure.Core;
using Bastion.Rules.Snapshot;
namespace Bastion.Scanner;
///
/// Reads service principals and their credentials from Microsoft Graph. Kept as a plain HTTP
/// client over the two Graph endpoints we need rather than pulling in the full Graph SDK — the
/// shape we read is tiny and stable. Requires the calling identity to hold
/// Application.Read.All (application) or Directory.Read.All.
///
public sealed class GraphServicePrincipalReader
{
private static readonly string[] GraphScope = ["https://graph.microsoft.com/.default"];
private readonly HttpClient _http;
private readonly TokenCredential _credential;
public GraphServicePrincipalReader(HttpClient http, TokenCredential credential)
{
_http = http;
_credential = credential;
}
public async Task> ReadAsync(CancellationToken cancellationToken = default)
{
var token = await _credential.GetTokenAsync(new TokenRequestContext(GraphScope), cancellationToken);
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
var principals = new List();
var url = "https://graph.microsoft.com/v1.0/servicePrincipals"
+ "?$select=id,displayName,passwordCredentials,keyCredentials&$top=100";
while (url is not null)
{
using var response = await _http.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
foreach (var item in doc.RootElement.GetProperty("value").EnumerateArray())
{
principals.Add(Map(item));
}
url = doc.RootElement.TryGetProperty("@odata.nextLink", out var next) ? next.GetString() : null;
}
return principals;
}
private static ServicePrincipal Map(JsonElement item)
{
var credentials = new List();
foreach (var (property, kind) in new[]
{
("passwordCredentials", CredentialKind.ClientSecret),
("keyCredentials", CredentialKind.Certificate),
})
{
if (!item.TryGetProperty(property, out var list))
{
continue;
}
foreach (var cred in list.EnumerateArray())
{
credentials.Add(new CredentialInfo
{
Kind = kind,
CreatedAt = ReadDate(cred, "startDateTime"),
ExpiresAt = ReadDate(cred, "endDateTime"),
});
}
}
return new ServicePrincipal
{
ObjectId = item.GetProperty("id").GetString() ?? "",
DisplayName = item.TryGetProperty("displayName", out var name) ? name.GetString() ?? "" : "",
Credentials = credentials,
};
}
private static DateTimeOffset ReadDate(JsonElement element, string property) =>
element.TryGetProperty(property, out var value) && value.TryGetDateTimeOffset(out var parsed)
? parsed
: DateTimeOffset.MinValue;
}