Who Ran That Migration?

Who Ran That Migration?

A developer runs dotnet ef database update against production. Fifteen minutes later, the database behaves strangely. Three hours into the incident, someone asks the obvious question: “Who ran that migration?”

Silence.

The terminal window closed. The build log expired last week. Nobody remembers. The migration tool printed “Success” to the console and promptly forgot everything.

This scenario repeats across organizations constantly. Database migrations, deployment scripts, data cleanup tools, configuration utilities: they all share the same blind spot. They execute privileged operations that modify production systems, then vanish without a trace.

Your web applications log every user action. Your APIs track every request. But your CLI tools? They are operating in the shadows, modifying databases, deploying code, deleting records. All without leaving evidence of who did what, when they did it, or whether it even succeeded.

That missing accountability will bite you. During incident investigations, when you need to understand what happened. During security reviews, when you need to prove who had access. During compliance assessments, when auditors ask uncomfortable questions about privileged operations.

The fix is straightforward: treat CLI tools like the privileged applications they are. Structured logging. User identity tracking. Correlation IDs. Persistent storage. The same discipline you apply to production web apps.

The Console.WriteLine Problem

Every .NET developer has written code like this:

public class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Starting database migration...");
        await using var context = new AppDbContext();
        await context.Database.MigrateAsync();
        Console.WriteLine("Migration completed successfully");
    }
}

Functionally correct. Operationally blind.

Three months later, someone asks: “Who ran this migration? When exactly? Against which environment?” The answers do not exist. Console output died with the terminal session. Build logs expired. PowerShell transcripts only captured interactive sessions, and this ran from a scheduled task.

The pattern repeats everywhere. Deployment tools that Console.WriteLine their progress then forget everything. Admin scripts that run as service accounts, severing any link to the human who triggered them. Data cleanup utilities that delete records (sometimes including the logs that would track such deletions) without leaving a trace.

This is not an edge case. This is the default behavior of virtually every custom CLI tool in enterprise .NET environments. And it creates real problems.

Why Missing Logs Hurt

The problems surface in predictable scenarios.

Incident investigations stall when nobody can determine what changed. “Someone ran something last week” is not actionable intelligence. Without timestamps, user identities, and operation details, root cause analysis becomes archaeology instead of forensics.

Security reviews fail when you cannot demonstrate who has accessed what. Privileged operations require accountability. Service accounts that hide human identity defeat the purpose of access controls.

Compliance assessments get uncomfortable when auditors ask about privileged operation logging and you have to explain that your CLI tools print to console and hope someone is watching. Every security framework, from SOC 2 to ISO 27001 to GDPR, requires logging user actions, timestamps, successes, failures, and affected resources. Console output satisfies none of these requirements.

The common thread: you need evidence. Evidence of who did what, when they did it, and what the outcome was. Evidence that persists beyond terminal sessions and build log retention periods. Evidence you can query, filter, and present.

The Fix: Structured Logging with Identity

The solution is not complicated. It requires discipline, not genius. Every CLI tool needs four things: user identity, correlation IDs, structured events, and persistent storage.

Here is a practical implementation using Microsoft.Extensions.Logging and Application Insights:

public class AuditedCliTool
{
    private readonly ILogger<AuditedCliTool> _logger;
    private readonly TelemetryClient _telemetry;
    private readonly string _correlationId = Guid.NewGuid().ToString();
    private readonly string _userIdentity;

    public AuditedCliTool(ILogger<AuditedCliTool> logger, TelemetryClient telemetry)
    {
        _logger = logger;
        _telemetry = telemetry;
        _userIdentity = ResolveUserIdentity();
    }

    private string ResolveUserIdentity()
    {
        // Try Windows identity first (corporate environments)
        var windowsIdentity = WindowsIdentity.GetCurrent();
        if (!string.IsNullOrEmpty(windowsIdentity?.Name))
            return windowsIdentity.Name;

        // CI/CD pipelines set this via environment variable
        var ciUser = Environment.GetEnvironmentVariable("AUDIT_USER_PRINCIPAL");
        if (!string.IsNullOrEmpty(ciUser))
            return ciUser;

        // Fallback: machine + username (better than nothing)
        return $"{Environment.MachineName}\\{Environment.UserName}";
    }

    public async Task<int> ExecuteAsync(string operation, string target)
    {
        var context = new Dictionary<string, string>
        {
            ["CorrelationId"] = _correlationId,
            ["User"] = _userIdentity,
            ["Operation"] = operation,
            ["Target"] = target,
            ["Machine"] = Environment.MachineName
        };

        using (_logger.BeginScope(context))
        {
            _logger.LogWarning("OPERATION_START: {User} initiated {Operation} on {Target}",
                _userIdentity, operation, target);
            _telemetry.TrackEvent($"{operation}Started", context);

            try
            {
                await PerformOperation(operation, target);

                context["Status"] = "Success";
                _logger.LogWarning("OPERATION_COMPLETE: {Operation} succeeded for {User}",
                    operation, _userIdentity);
                _telemetry.TrackEvent($"{operation}Completed", context);
                return 0;
            }
            catch (Exception ex)
            {
                context["Status"] = "Failed";
                context["Error"] = ex.Message;
                _logger.LogError(ex, "OPERATION_FAILED: {Operation} failed for {User}",
                    operation, _userIdentity);
                _telemetry.TrackEvent($"{operation}Failed", context);
                return 1;
            }
        }
    }
}

The key elements deserve explanation.

User identity resolution tries multiple strategies because CLI tools run in diverse environments. Windows authentication works on corporate networks. Environment variables work in CI/CD pipelines where you control the execution context. The fallback ensures you always capture something, even if it is just the machine name.

Correlation IDs tie everything together. When this CLI tool triggers downstream operations (API calls, database queries, message queue publishes), the correlation ID follows. During incident investigation, you can trace the entire operation flow across systems.

Structured logging with scopes attaches context to every log entry. The BeginScope call ensures that CorrelationId, User, Operation, and Target appear on every log line within that block. Log aggregation tools can filter and query these fields.

Dual-channel logging sends events to both local logs and Application Insights. If one system fails, you still have records. Application Insights provides real-time querying; local logs provide backup and extended retention.

CI/CD Pipelines: The Identity Problem

CI/CD pipelines add a wrinkle. Your CLI tool runs, but it runs as github-actions[bot] or whatever service account executes the pipeline. The human who triggered the deployment disappears from the record.

The fix is propagating context. GitHub Actions provides github.actor (the human who triggered the workflow), github.run_id (unique identifier for correlation), and github.sha (the exact code version). Pass these to your CLI tools:

- name: Deploy with audit context
  env:
    AUDIT_USER_PRINCIPAL: ${{ github.actor }}@github-actions
    AUDIT_CORRELATION_ID: ${{ github.run_id }}-${{ github.run_attempt }}
  run: |
    dotnet run --project DeploymentTool -- \
      --environment Production \
      --correlation-id "${{ env.AUDIT_CORRELATION_ID }}"

Now your CLI tool receives the actual human identity through the environment variable. The correlation ID links Application Insights telemetry back to the specific GitHub Actions run. You can trace from a production incident to the exact workflow execution and the person who triggered it.

For long-term retention, upload logs as artifacts with extended retention periods. GitHub Actions deletes run logs after 90 days by default. Artifacts can persist for years if you configure retention-days appropriately.

Querying Your Logs

Collecting logs is pointless if you cannot query them. When someone asks “Who ran that migration last Tuesday?”, you need answers in minutes, not hours of log archaeology.

Application Insights provides KQL (Kusto Query Language) for this:

customEvents
| where name == "MigrationCompleted"
| where timestamp > ago(7d)
| project timestamp,
          user = customDimensions.User,
          target = customDimensions.Target,
          correlationId = customDimensions.CorrelationId
| order by timestamp desc

This query finds all migrations in the last week, showing who ran them and what they targeted. The correlation ID lets you trace related operations across systems.

For incident investigation, start with the correlation ID and expand outward:

let targetCorrelation = "abc-123-def";
union traces, customEvents, exceptions
| where customDimensions.CorrelationId == targetCorrelation
| order by timestamp asc

This reconstructs the complete operation timeline: what started, what succeeded, what failed, and in what order. Forensics, not archaeology.

Protecting Your Logs

Logs that can be modified or deleted are not evidence. They are suggestions. For logs to have value during investigations or compliance reviews, they need protection.

Application Insights provides built-in immutability: you cannot modify or delete individual events. Only entire workspaces can be purged, and only after retention periods expire. For most scenarios, this is sufficient.

If you need stronger guarantees, export to Azure Blob Storage with immutability policies enabled. Once uploaded, even administrators cannot modify or delete the files until the retention period expires. Seven years is typical for regulatory requirements.

For the truly paranoid, cryptographic signatures provide tamper evidence. Hash the log export, sign the hash, store the signature separately. Any modification invalidates the signature, proving tampering occurred. This is overkill for most organizations, but some regulated industries require it.

The Checklist

Implementing this is straightforward once you commit to the discipline:

Every CLI tool needs:

  • Structured logging via Microsoft.Extensions.Logging
  • User identity resolution (Windows, environment variable, fallback)
  • Correlation IDs for cross-system tracing
  • Start, success, and failure events logged explicitly
  • Sensitive data sanitized before logging
  • Centralized log storage (Application Insights, Elasticsearch, whatever you use)

CI/CD pipelines need:

  • Human identity propagated through environment variables
  • Workflow run IDs used as correlation IDs
  • Log artifacts uploaded with appropriate retention periods

Log storage needs:

  • Retention periods matching your compliance requirements
  • Immutability policies preventing modification
  • Access controls limiting who can read sensitive logs

The Real Payoff

Compliance requirements drove this article, but compliance is not the real reason to implement proper CLI logging. The real reason is operational sanity.

I have investigated production incidents where the only evidence was “someone ran something last week.” Hours wasted on archaeology when forensics would have taken minutes. Proper logs with user identities, timestamps, correlation IDs, and outcomes transform incident investigation from guesswork into reconstruction.

Security incident response benefits identically. When your SIEM alerts on suspicious database activity, audit trails immediately answer whether this was a legitimate admin operation or an actual breach. Fast answers depend on complete records.

Compliance gives you the justification. Operational excellence is the payoff.

Conclusion

Your CLI tools are lying to you. They run, they print success, they forget everything.

That amnesia creates real problems during incidents, security reviews, and compliance assessments. The fix is not complicated: structured logging, user identity tracking, correlation IDs, persistent storage. The same discipline you apply to production web applications.

The .NET ecosystem provides everything you need. Microsoft.Extensions.Logging handles structured logging. Application Insights provides centralized storage and querying. GitHub Actions context variables enable identity propagation in CI/CD pipelines.

Build this into your CLI tools from day one. Not as an afterthought when someone asks uncomfortable questions. As a fundamental requirement for any tool that touches production systems.

Future you, investigating a 2 AM incident, will appreciate the evidence.

Comments

VG Wort