feat: Message Center ingestion, feed filtering, and the workload identity behind it - #4
Merged
Merged
Conversation
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.
Dependency ReviewThe following issues were found:
License Issues.github/workflows/terraform.yml
OpenSSF Scorecard
Scanned Files
|
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 holdingServiceMessage.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
Sourcegainsinclude_categoriesandexclude_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_entriescut, 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
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
fromisoformatrejects, so they are trimmed before parsing.The identity
One application registration plus service principal,
svp-ldo-uks-prd-mc-001, composinglibre-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, andfetch-feeds.ymldeclaresenvironment: github-pages:repo:libre-devops/security-news:environment:github-pagesrepo:libre-devops@101948202/...repo:libre-devops/security-news:ref:refs/heads/masterrepo:libre-devops@101948202/...The immutable
org@idformat 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 withAADSTS7002131.Consent stays out of the pipeline
grant_admin_consentdefaults to false. Granting a Graph application role needsAppRoleAssignment.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 consentor thegrant_admin_consent_commandsoutput. Terraform does not manage the grant, so it will not try to remove it.Pipeline and local dev
.github/workflows/terraform.ymlrunslibre-devops/terraform-azure@v1: plan on any PR touchingterraform/**, apply only on manual dispatch. Fork PRs are skipped, since GitHub issues them no OIDC token.The
justfilewraps 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, plusfeeds,feeds-dry,serve,consentandpublish-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_IDandMESSAGE_CENTER_TENANT_ID, notAZURE_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 --checkclean; the fetcher run end to end producing 150 articles with the new sources landing and the Graph 403 handled.just --list,just fmtandjust py-checkall 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
just dispatch(orgh workflow run terraform.yml -f apply=true).just consent, and run the printed command.just publish-vars.