Skip to content

feat: Message Center ingestion, feed filtering, and the workload identity behind it - #4

Merged
craigthackerx merged 5 commits into
masterfrom
feat/message-center-app
Aug 8, 2026
Merged

feat: Message Center ingestion, feed filtering, and the workload identity behind it#4
craigthackerx merged 5 commits into
masterfrom
feat/message-center-app

Conversation

@craigthackerx

@craigthackerx craigthackerx commented Aug 8, 2026

Copy link
Copy Markdown

Adds Microsoft 365 Message Center as a source, the filtering the broad Microsoft feeds need to be usable, the Entra ID application that authenticates the Graph call, and a justfile for local development.

Why Message Center needs any of this

Every other source is public RSS or Atom and needs no credentials. Message Center has no feed of any kind, only Graph at /admin/serviceAnnouncement/messages, which is tenant scoped and needs an authenticated caller holding ServiceMessage.Read.All.

I checked the public alternatives first and they do not substitute for it. The M365 Roadmap RSS carries 1819 items with zero mentioning Sentinel and only 18 categorised under any Defender product (all Defender for Office 365); the Azure updates feed has one Defender for Cloud item and zero Sentinel. Both ignore their own ?filters= parameter server side.

Feed filtering

Source gains include_categories and exclude_categories, matched case-insensitively against an entry's own <category> terms (or, for Message Center, the services a post applies to).

The filter runs before the max_entries cut, which is the whole point. Slicing first would take the newest 25 items and then filter them, so a broad feed would usually yield nothing.

Three new sources

Source Raw Kept Filter
M365 Roadmap 1819 30 Defender, Sentinel, Purview, Entra, Intune
Azure Service Updates 200 22 Security, Compliance, Identity, security services
Message Center Graph up to 40 security services

Copilot is deliberately off the roadmap allowlist: it is the single largest category and almost none of it is security.

Worth setting expectations, since it matches what I found above: after the 30 day cutoff the surviving roadmap items are all Purview. The roadmap simply does not carry XDR or Sentinel content. Azure updates does better, landing Azure Firewall, Key Vault, network security perimeter and Trusted Launch items.

The Graph path degrades instead of failing

Token acquisition tries, in order: an explicit MESSAGE_CENTER_ACCESS_TOKEN, the GitHub Actions OIDC exchange, then the Azure CLI for local work. If none succeeds, or Graph returns 403 because consent has not been granted, it logs and returns nothing. Message Center is one source among many, so losing it costs the site that section, not the whole run.

Verified end to end locally: the CLI token was obtained, Graph returned 403, the error was named as a consent problem, and the run still produced 150 articles.

Graph emits up to seven fractional-second digits on timestamps, which fromisoformat rejects, so they are trimmed before parsing.

The identity

One application registration plus service principal, svp-ldo-uks-prd-mc-001, composing libre-devops/service-principal/azuread ~> 4.0. No client secret and no certificate: federated GitHub Actions OIDC credentials are the only credential, so nothing secret lands in the repo, in Actions, or in state.

Four subjects are trusted, because a job declaring an environment: presents a different OIDC subject from one that does not, and fetch-feeds.yml declares environment: github-pages:

plain immutable
environment repo:libre-devops/security-news:environment:github-pages repo:libre-devops@101948202/...
ref repo:libre-devops/security-news:ref:refs/heads/master repo:libre-devops@101948202/...

The immutable org@id format is what GitHub forces on repos created or renamed after 2026-07-15. This one predates that, but a rename would flip it and fail with AADSTS7002131.

Consent stays out of the pipeline

grant_admin_consent defaults to false. Granting a Graph application role needs AppRoleAssignment.ReadWrite.All, which lets its holder assign any app role of any API to any principal, including granting itself directory write. On the shared org CI identity, usable by every repo in the org, that would turn a workflow-file edit into a tenant escalation path.

So the pipeline requests the role and it gets consented once by hand, via just consent or the grant_admin_consent_commands output. Terraform does not manage the grant, so it will not try to remove it.

Pipeline and local dev

.github/workflows/terraform.yml runs libre-devops/terraform-azure@v1: plan on any PR touching terraform/**, apply only on manual dispatch. Fork PRs are skipped, since GitHub issues them no OIDC token.

The justfile wraps the LibreDevOpsHelpers engine so local work mirrors the action: the same fmt, validate, tflint and trivy gates, the same remote backend, the same storage firewall open-before close-after dance, plus feeds, feeds-dry, serve, consent and publish-vars.

The state key is pinned in both. The helper derives one from the folder layout, which resolves differently on a runner (GitHub nests the repo inside a directory of the same name) than on a laptop. Left automatic, CI and local would have used two separate states, and created two application registrations.

Variable naming

The published identifiers are MESSAGE_CENTER_CLIENT_ID and MESSAGE_CENTER_TENANT_ID, not AZURE_CLIENT_ID / AZURE_TENANT_ID. Those already exist as org variables for the CI identity, and a repository variable of the same name silently shadows the org one, which would have broken this repo's own Terraform pipeline.

Verification

  • terraform fmt -check, validate, and the pipeline plan green on this PR: OIDC login, remote backend, state lock, Plan: 8 to add, 0 to change, 0 to destroy, consent grant correctly absent.
  • black --check clean; the fetcher run end to end producing 150 articles with the new sources landing and the Graph 403 handled.
  • just --list, just fmt and just py-check all run. The shebang recipes (validate, scan, plan, apply) could not be executed in my sandbox, but the identical pattern in the existing module justfiles fails there the same way, so that is the sandbox and not these recipes.

Nothing has been applied.

To land

  1. Merge.
  2. just dispatch (or gh workflow run terraform.yml -f apply=true).
  3. just consent, and run the printed command.
  4. just publish-vars.

Message Center is the one source with no feed: it is Graph only, at
/admin/serviceAnnouncement/messages, and needs an authenticated tenant-scoped
caller holding ServiceMessage.Read.All. This adds the Entra application that
provides it, composing libre-devops/service-principal/azuread.

Federated to GitHub Actions by OIDC with no client secret and no certificate,
so nothing secret lands in the repository, in Actions, or in state.

Four subjects are trusted: the environment form (what the feed job presents,
since it declares environment: github-pages for Pages) and the ref form, each
in both the plain and the immutable org@id format GitHub forces on repositories
created or renamed after 2026-07-15.

Verified with fmt, validate and a read-only plan against the tenant: 9 to add,
subjects and the Graph role resolve as intended.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 2 package(s) with unknown licenses.
See the Details below.

License Issues

.github/workflows/terraform.yml

PackageVersionLicenseIssue Type
actions/checkout7.*.*NullUnknown License
libre-devops/terraform-azure1.*.*NullUnknown License

OpenSSF Scorecard

PackageVersionScoreDetails
actions/actions/checkout 7.*.* 🟢 6.9
Details
CheckScoreReason
Binary-Artifacts🟢 10no binaries found in the repo
Code-Review🟢 10all changesets reviewed
Maintained🟢 1024 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
Packaging⚠️ -1packaging workflow not detected
License🟢 10license file detected
Pinned-Dependencies🟢 3dependency not pinned by hash detected -- score normalized to 3
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
SAST🟢 10SAST tool is run on all commits
Branch-Protection🟢 5branch protection is not maximal on development and all release branches
actions/libre-devops/terraform-azure 1.*.* UnknownUnknown

Scanned Files

  • .github/workflows/terraform.yml

Local state for something a scheduled production job depends on was the wrong
call: no history, no locking, and the app registration goes unmanaged the moment
that laptop does. Moves to the estate convention instead.

- azurerm backend, configured at init from the org TFSTATE_* secrets, with a
  backend_override.tf.example for local plan-only work.
- Provider on use_oidc, so CI authenticates as the org CI identity with no
  client secret. Local az login still works, verified by plan.
- terraform.yml: plan on any PR touching terraform/, apply only on a manual
  dispatch. Fork PRs are skipped, since GitHub issues them no OIDC token.

grant_admin_consent now defaults to false. Granting a Graph application role
needs AppRoleAssignment.ReadWrite.All, which lets its holder assign any app role
of any API to any principal, including granting itself directory write. On the
shared org CI identity that turns a workflow-file edit into a tenant escalation
path, so the pipeline requests the role and a human consents once out of band.

Also renames the published Actions variables to MESSAGE_CENTER_CLIENT_ID and
MESSAGE_CENTER_TENANT_ID. AZURE_CLIENT_ID and AZURE_TENANT_ID already exist as
org variables for the CI identity, and a repository variable of the same name
shadows the org one, which would have broken this repository's own pipeline.

Verified with fmt, validate and a local plan: 8 to add, the consent grant
correctly absent while the permission request stays managed.
uses: actions/checkout@v7

- name: Terraform
uses: libre-devops/terraform-azure@v1
The pipeline plan showed the owner resolving to the org CI service principal,
because owners came from azuread_client_config and that is whoever applied.
Applied from CI the application would end up under nobody's owned applications
and need a directory role to touch in the portal.

Adds additional_owner_object_ids, unioned with the applier so the pipeline can
still manage what it created.
Source gains include_categories and exclude_categories, matched against an
entry's own categories. The filter runs BEFORE the max_entries cut, which is the
whole point: slicing first would take the newest 25 items and then filter them,
so a broad feed like the roadmap would usually yield nothing.

Three sources on top of that:

- Microsoft 365 Roadmap, filtered to the Defender, Sentinel, Purview, Entra and
  Intune categories. Copilot is deliberately excluded, being the largest
  category by far and almost none of it security. 1819 raw items in, 30 kept.
- Azure Service Updates, filtered to Security, Compliance, Identity and the
  security services. 200 raw in, 22 kept.
- Message Center over Graph, the one source with no feed of any kind.

The Graph path takes a token from whichever route is available: an explicit
MESSAGE_CENTER_ACCESS_TOKEN, the GitHub Actions OIDC exchange, or the Azure CLI
for local work. When none is available, or consent has not been granted, it logs
and returns nothing, so the site degrades to its public feeds rather than
failing the run. Verified: 403 from Graph is caught and named as a consent
problem, and the run still produced 150 articles.

Graph timestamps carry up to seven fractional-second digits, which
fromisoformat rejects, so they are trimmed before parsing.

The justfile wraps the LibreDevOpsHelpers engine so local work mirrors the
action: the same fmt, validate, tflint and trivy gates, the same remote backend
and the same storage firewall dance, plus feed and site recipes.

The state key is now pinned in both the workflow and the justfile. The helper
derives one from the folder layout, which resolves differently on a runner
(GitHub nests the repo inside a directory of the same name) than on a laptop,
so leaving it automatic would have given CI and local two separate states, and
two application registrations.
@craigthackerx craigthackerx changed the title feat: add terraform for the Message Center workload identity feat: Message Center ingestion, feed filtering, and the workload identity behind it Aug 8, 2026
…imit

Pre-flight against the tenant showed the org CI identity already holds
AppRoleAssignment.ReadWrite.All, alongside RoleManagement.ReadWrite.Directory
and Directory.ReadWrite.All. The earlier wording justified the default on the
CI identity not carrying that permission, which is simply not true, so it
overstated the case. The default stays false because consent should be a
deliberate act, not because the pipeline is incapable of it.
@craigthackerx
craigthackerx merged commit 39d833b into master Aug 8, 2026
6 checks passed
@craigthackerx
craigthackerx deleted the feat/message-center-app branch August 8, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants