Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion .github/workflows/publish-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ permissions:
contents: write
id-token: write

env:
MODULE_PATH: ./artifacts/powershell-package/OwnerLens
KEY_VAULT_URL: ${{ vars.KEY_VAULT_URL }}
SIGNING_CERT_NAME: ${{ vars.SIGNING_CERT_NAME }}

jobs:
publish:
runs-on: ubuntu-latest
Expand All @@ -22,6 +27,9 @@ jobs:
node-version: "24"
registry-url: "https://registry.npmjs.org"

- run: npm ci
- run: npm run lint

- name: Set package version from tag
id: set_version
run: |
Expand All @@ -34,7 +42,6 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
npm version "$VERSION" --no-git-tag-version --allow-same-version

- run: npm ci
- run: npm test --if-present
- run: npm run test:components --if-present
- run: npm run build --if-present
Expand Down Expand Up @@ -62,6 +69,76 @@ jobs:
$version = "${{ needs.publish.outputs.version }}"
./scripts/package-powershell-module.ps1 -Version $version

- name: Azure login via OIDC
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

- name: Install signing and publishing tools
shell: pwsh
run: |
$ErrorActionPreference = "Stop"

dotnet tool install --global AzureSignTool

Install-Module PowerShellGet -Force -Scope CurrentUser
Install-Module PackageManagement -Force -Scope CurrentUser

- name: Sign PowerShell files
shell: pwsh
run: |
$ErrorActionPreference = "Stop"

$files = Get-ChildItem $env:MODULE_PATH -Recurse -File |
Where-Object { $_.Extension -in ".ps1", ".psm1", ".psd1" }

if (-not $files) {
throw "No PowerShell files found to sign."
}

foreach ($file in $files) {
Write-Host "Signing $($file.FullName)"

azuresigntool sign `
-kvu $env:KEY_VAULT_URL `
-kvc $env:SIGNING_CERT_NAME `
-kvm `
-fd sha256 `
-tr "http://timestamp.digicert.com" `
-td sha256 `
-v `
$file.FullName

$sig = Get-AuthenticodeSignature -FilePath $file.FullName

if ($sig.Status -eq "NotSigned") {
throw "File was not signed: $($file.FullName)"
}

if (-not $sig.TimeStamperCertificate) {
throw "Missing timestamp: $($file.FullName)"
}

Write-Host "Signed: $($file.Name) / Status: $($sig.Status)"
}

- name: Refresh signed PowerShell module release assets
shell: pwsh
run: |
$ErrorActionPreference = "Stop"

$version = "${{ needs.publish.outputs.version }}"
$zipPath = "artifacts/release/OwnerLens-$version-win-x64.zip"
$checksumPath = "$zipPath.sha256"

Remove-Item -LiteralPath $zipPath, $checksumPath -Force -ErrorAction SilentlyContinue
Compress-Archive -Path $env:MODULE_PATH -DestinationPath $zipPath -CompressionLevel Optimal

$hash = Get-FileHash -Path $zipPath -Algorithm SHA256
Set-Content -Path $checksumPath -Value "$($hash.Hash.ToLowerInvariant()) $(Split-Path -Leaf $zipPath)" -Encoding ascii

- name: Upload PowerShell module release assets
shell: pwsh
env:
Expand All @@ -84,3 +161,15 @@ jobs:
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload PowerShell module release assets for $tag."
}

- name: Publish module to PowerShell Gallery
shell: pwsh
env:
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
run: |
$ErrorActionPreference = "Stop"

Publish-Module `
-Path $env:MODULE_PATH `
-NuGetApiKey $env:PSGALLERY_API_KEY `
-Verbose
45 changes: 45 additions & 0 deletions .infra/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# OwnerLens Signing Infrastructure

This folder contains the one-time Azure Key Vault setup for OwnerLens code-signing assets.

## Deploy Key Vault

create rg
```bash
az group create -n rg-ownerlens-signing -l westeurope

```
create deployment

```bash
az deployment group create \
--resource-group rg-ownerlens-signing \
--template-file infra/keyvault.bicep \
--parameters keyVaultName=kv-ownerlens-signing
```

Assign access to the pipeline identity manually on the Key Vault. Minimum practical RBAC roles:

- Key Vault Crypto User
- Key Vault Certificate User

If using access policies instead of RBAC, the pipeline identity needs approximately:

- certificates: get, list
- keys: get, sign, verify

## Create Code-Signing Certificate

Create the certificate once during bootstrap, not in every pipeline run:

```powershell
./infra/create-code-signing-cert.ps1 `
-VaultName "kv-ownerlens-signing" `
-CertificateName "ownerlens-code-signing"
```

Check certificate creation status:

```powershell
Get-AzKeyVaultCertificateOperation -VaultName "kv-ownerlens-signing" -Name "ownerlens-code-signing"
```
33 changes: 33 additions & 0 deletions .infra/create-code-signing-cert.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
param(
[Parameter(Mandatory)]
[string]$VaultName,

[Parameter(Mandatory)]
[string]$CertificateName,

[string]$Subject = "CN=OwnerLens Code Signing"
)

$ErrorActionPreference = "Stop"

$codeSigningEku = "1.3.6.1.5.5.7.3.3"

$policy = New-AzKeyVaultCertificatePolicy `
-IssuerName "Self" `
-SubjectName $Subject `
-SecretContentType "application/x-pkcs12" `
-KeyType RSA `
-KeySize 4096 `
-KeyUsage DigitalSignature `
-Ekus $codeSigningEku `
-ValidityInMonths 12 `
-KeyNotExportable `
-EmailAtPercentageLifetime 80

Add-AzKeyVaultCertificate `
-VaultName $VaultName `
-Name $CertificateName `
-CertificatePolicy $policy

Write-Host "Certificate creation started. Check completion:"
Write-Host "Get-AzKeyVaultCertificateOperation -VaultName $VaultName -Name $CertificateName"
29 changes: 29 additions & 0 deletions .infra/keyvault.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
param location string = resourceGroup().location
param keyVaultName string

resource kv 'Microsoft.KeyVault/vaults@2024-11-01' = {
name: keyVaultName
location: location
properties: {
tenantId: tenant().tenantId
sku: {
family: 'A'
name: 'standard'
}

// Access is assigned manually in Azure.
enableRbacAuthorization: true

enableSoftDelete: true
softDeleteRetentionInDays: 90
enablePurgeProtection: true

publicNetworkAccess: 'Enabled'
networkAcls: {
bypass: 'AzureServices'
defaultAction: 'Allow'
}
}
}

output keyVaultUri string = kv.properties.vaultUri
4 changes: 2 additions & 2 deletions contracts/runtime.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1998,14 +1998,14 @@
"additionalProperties": true,
"required": [
"target",
"items"
"evidence"
],
"properties": {
"target": {
"type": "object",
"additionalProperties": true
},
"items": {
"evidence": {
"type": "array",
"items": {
"type": "object",
Expand Down
6 changes: 4 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AzureInventoryStats } from "./components/azure/AzureInventoryStats";
export default function App() {
return (
<main className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-[80vw] min-w-0 max-w-none flex-col gap-4 py-4 max-lg:w-[calc(100vw-2rem)]">
<div className="mx-auto flex min-h-screen w-full min-w-0 max-w-none flex-col gap-4 py-4 min-[1920px]:w-[80vw]">
<header className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-4 border-b border-border bg-background/90 px-4 py-3 backdrop-blur md:px-6">
<div className="shrink-0">
<h1 className="text-3xl font-semibold tracking-tight">OwnerLens</h1>
Expand All @@ -15,7 +15,9 @@ export default function App() {
</div>
</header>

<AzureComponent />
<div className="p-[5px]">
<AzureComponent />
</div>
</div>
</main>
);
Expand Down
51 changes: 51 additions & 0 deletions src/components/azure/AzureComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,7 @@ test("sets resource group owner candidate status to inactive from the evidence t
await waitForText(container, "rg-app");
await clickButton("Open ownership evidence for alice@example.test");
await waitForText(container, "Activity log");
expect(queryButton("Toggle ownership evidence option")).toBeNull();

const evidenceRequest = fetchMock.mock.calls
.map(([input]) => String(input))
Expand Down Expand Up @@ -1377,6 +1378,42 @@ test("opens ownership evidence for application owner evidence", async () => {
});
}

if (requestUrl.startsWith("/api/data/azureRbac")) {
return jsonResponse({
collectionId: "azureRbac",
columns: [],
count: 1,
page: 1,
pageSize: 20,
rows: [
{
accessDisplayName: "Reader on application subscription",
accessRisk: "low",
accessResourceGroup: null,
accessResourceId: null,
accessScope: "/subscriptions/sub-1",
accessScopeType: "Subscription",
accessSubscriptionId: "sub-1",
canDelegate: false,
condition: null,
conditionVersion: null,
principalDisplayName: "Application owner app",
principalId: "application-object-id",
principalType: "ServicePrincipal",
roleAssignmentId: "assignment-application",
roleDefinitionId: "reader-role-id",
roleDefinitionName: "Reader",
scope: "/subscriptions/sub-1",
scopeSubscriptionId: "sub-1",
servicePrincipalId: "application-object-id",
signInName: null,
subscriptionId: "sub-1",
subscriptionName: "Platform"
}
]
});
}

return servicePrincipalOwnerResponse({
displayName: "Application owner app",
type: "application"
Expand All @@ -1389,6 +1426,20 @@ test("opens ownership evidence for application owner evidence", async () => {
await waitForText(container, "Service principal app");
await clickButton("Open ownership evidence for Application owner app");
await waitForText(container, "Application owner");
await clickButton("Open application Azure RBAC assignments for Application owner app");
await waitForText(container, "Reader on application subscription");

const applicationRbacRequest = fetchMock.mock.calls
.map(([input]) => String(input))
.find((requestUrl) => requestUrl.startsWith("/api/data/azureRbac"));
expect(applicationRbacRequest).toBeDefined();

const rbacUrl = new URL(applicationRbacRequest ?? "", window.location.origin);
expect(rbacUrl.searchParams.get("servicePrincipalId")).toBe("application-object-id");

await clickButton("Close Application owner app Azure RBAC tab");
await waitForText(container, "Application owner");

await clickButton("Open application ownership evidence for Application owner app");
await waitForText(container, "No ownership evidence was found.");

Expand Down
12 changes: 9 additions & 3 deletions src/components/azure/AzureComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ type PersistentTableControls = {

type AzureRbacTab = AzureRbacPrincipalSelection & {
kind: "servicePrincipal";
returnView: Extract<AzureView, "servicePrincipals" | "managedIdentities" | "remediationPackage">;
returnView: Extract<AzureView, "servicePrincipals" | "managedIdentities" | "ownershipEvidence" | "remediationPackage">;
} | AzureRbacResourceGroupSelection & {
kind: "resourceGroup";
returnView: Extract<AzureView, "resourceGroups">;
Expand Down Expand Up @@ -133,7 +133,7 @@ export function AzureComponent() {

function openAzureRbac(
principal: AzureRbacPrincipalSelection,
returnView: Extract<AzureRbacTab["returnView"], "servicePrincipals" | "managedIdentities" | "remediationPackage">
returnView: Extract<AzureRbacTab["returnView"], "servicePrincipals" | "managedIdentities" | "ownershipEvidence" | "remediationPackage">
) {
setAzureRbacTab({ ...principal, kind: "servicePrincipal", returnView });
activateView("azureRbac");
Expand Down Expand Up @@ -201,6 +201,7 @@ export function AzureComponent() {
}

const ownershipEvidenceDisplayName = ownershipEvidenceTab ? getOwnershipEvidenceTabDisplayName(ownershipEvidenceTab) : null;
const showOwnershipEvidenceToggle = ownershipEvidenceTab ? isPrincipalOwnershipEvidenceTab(ownershipEvidenceTab) : false;

return (
<section className="flex flex-col">
Expand Down Expand Up @@ -259,7 +260,7 @@ export function AzureComponent() {
) : null}
</TabsList>
</Tabs>
{activeView === "ownershipEvidence" ? (
{activeView === "ownershipEvidence" && showOwnershipEvidenceToggle ? (
<OwnershipEvidenceToggle
checked={ownershipEvidenceToggleEnabled}
onCheckedChange={setOwnershipEvidenceToggleEnabled}
Expand Down Expand Up @@ -321,6 +322,7 @@ export function AzureComponent() {
azureRbac={ownershipEvidenceToggleEnabled}
displayName={ownershipEvidenceDisplayName ?? ownershipEvidenceTab.displayName}
target={ownershipEvidenceTab.target}
onAzureRbacClick={(principal) => openAzureRbac(principal, "ownershipEvidence")}
onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, ownershipEvidenceTab.returnView)}
/>
) : null}
Expand Down Expand Up @@ -411,6 +413,10 @@ function getOwnershipEvidenceTabDisplayName(tab: OwnershipEvidenceTab): string {
return `${prefix}: ${tab.displayName}`;
}

function isPrincipalOwnershipEvidenceTab(tab: OwnershipEvidenceTab): boolean {
return tab.target.kind === "servicePrincipal" || tab.target.kind === "managedIdentity";
}

function getAzureRbacTabKey(tab: AzureRbacTab): string {
return tab.kind === "servicePrincipal"
? tab.objectId
Expand Down
Loading
Loading