adoscope

package
v0.7.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 28, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package adoscope is the Azure DevOps CAPABILITY CATALOGUE: it names the access an Azure DevOps request needs, classifies one request to exactly one name, and maps a set of those names to the Entra scopes a token must carry.

It exists because "authorize this person as themselves" is unanswerable without a vocabulary: Azure DevOps asks for vso.code_write both to push a commit and to complete a pull request while BYPASSING branch policy, so a token minted from the scope the API asks for is strictly wider than the work it was minted for. The capability names below are what a run is held to; the scope strings are only what Entra understands.

THE PACKAGE IS PURE and imports the STANDARD LIBRARY ONLY. Two constraints keep it that way:

  • internal/types names Capability on a git-provider row, so importing internal/types here would be an import cycle.
  • the classifier is the one opinion about what a request needs. Importing internal/api or internal/egress would let a call site's own idea of a "read" leak in beside it, which is how two spellings of one rule start.

FAIL-CLOSED IS THE CONTRACT. Classify returns an error for a request it cannot reason about and CapUnclassifiedWrite for a write it does not recognize; neither is grantable, so a caller refuses both. A classifier that guessed would hand out a scope on a route nobody reviewed.

Index

Constants

View Source
const MaxBodyPeek = 256 << 10

MaxBodyPeek bounds what the classifier will look at. Three routes need the body — a pull-request completion, a ref move and a work-item batch — and all three are small requests; 256 KiB is far above any of them and far below anything worth buffering on a governed path. A body that does not fit is REFUSED, never classified on its visible prefix.

View Source
const ResourceID = "499b84ac-1321-427f-aa17-267ca6975798"

ResourceID is Azure DevOps' first-party application id — the audience every Entra scope for Azure DevOps is qualified by. It is a fixed Microsoft constant, the same value in every tenant, which is why it is a constant here and not a field on a provider row: a row carrying it could only ever carry it wrong.

Variables

View Source
var ErrNeedsBody = errors.New("adoscope: this route is classified on its body")

ErrNeedsBody is Classify's answer for a BodyWithheld request on a route it classifies by the body: peek the body, clear BodyWithheld and ask again.

Functions

func CanonicalPath added in v0.7.11

func CanonicalPath(path string) (string, bool)

CanonicalPath rewrites every non-empty segment of a URL path to EscapeName(UnescapeName(segment)), so each spelling of the same names is one string. ok=false when any segment does not decode — the caller keeps the original, and whatever refuses a percent-escape today still refuses it.

func CanonicalRepoURL added in v0.7.11

func CanonicalRepoURL(raw string, serverHosts []string) (string, bool)

CanonicalRepoURL is CanonicalURL for an Azure DevOps repository address: one whose host is an Azure DevOps service host (dev.azure.com and its subdomains, or <org>.visualstudio.com) or one of serverHosts — the Azure DevOps Server hosts a caller knows from its own configuration, lowercased. ok=false for anything else — another forge's URL above all — which callers leave untouched. There is deliberately no "the path has a _git segment" guess: a name rule applied to a forge that is not Azure DevOps would admit escapes that forge never needs.

func CanonicalURL added in v0.7.11

func CanonicalURL(raw string) (string, bool)

CanonicalURL is CanonicalPath applied to the path of an https:// or ssh:// URL, or of scp-form [user@]host:path. The scheme, userinfo and host are left exactly as written, and none may hold a "%": the name rule covers the path alone, and an escape anywhere else is one no caller reads the way a server would. ok=false for that, a query or fragment, a URL with no path, or a segment that does not decode.

func CheckRefName

func CheckRefName(ref string) error

CheckRefName refuses a ref name that is not one git would accept as it reads, or that a protected-ref check could read differently from the service. It is the ONE ref-name rule both Azure DevOps doors apply — the REST refs/pushes body here and the git broker's receive-pack command — so a name one door refuses cannot move a ref through the other.

  • ".." is a traversal: "refs/heads/wardyn/<run>/../../main" passes a run-namespace prefix test and names main;
  • a backslash is a separator to the service but not to a protected-branch cache keyed by the forward-slash spelling;
  • space, tab, "^", "~", ":", "?", "*" and "[" are forbidden by git's own check-ref-format, so refusing them costs no legitimate ref anything;
  • a control byte (an LF or CR above all) is how a second command rides a line-oriented reader.

func EscapeName added in v0.7.11

func EscapeName(name string) string

EscapeName is the ONE spelling of a name as a URL path segment: UnescapeName(EscapeName(n)) == n for every name the service permits.

It escapes as LITTLE as it can — whitespace, control characters, "%", and the four characters that would end the segment or the path — and leaves every other character literal. That is deliberate: a repository URL with "(", "&", "'" or "é" in it was already accepted verbatim before this rule existed, so escaping those too would give a stored row a second spelling of itself. What it does escape is exactly what a stored field must not carry (a space breaks the tab-framed WARDYN_REPOS and every whitespace guard) and what a URL cannot hold unescaped.

func GrantableCapabilityList

func GrantableCapabilityList() []string

GrantableCapabilityList is GrantableCapabilities as strings, the shape an error message joins.

func Label

func Label(c Capability) string

Label is c's plain-language rendering, or "" for a value outside the set — an empty string rather than a guess, so a caller cannot render an unrecognized capability as though it were understood.

func NameKey added in v0.7.11

func NameKey(raw string) string

NameKey is the comparison key of a path segment naming a project or repository: its decoded name, case-folded because Azure DevOps resolves names case-insensitively in its URLs. "" when the segment does not decode.

func Permits

func Permits(granted []Capability, v Verdict) bool

Permits is THE gate: may a run holding granted perform v?

It is the only spelling of "allowed" this package offers, and it is exported so that no consumer writes its own. The two ways to get this wrong are both closed here: a non-grantable capability (an unclassified write, a denied area) is never permitted whatever is granted, and the comparison is over CAPABILITIES rather than scopes — an Entra token carries every scope the person consented to, so a scope comparison would permit essentially everything.

func ScopesFor

func ScopesFor(caps []Capability) ([]string, error)

ScopesFor is the resource-qualified, deduplicated, sorted scope set for caps.

A capability that is NOT GRANTABLE is an ERROR, not an empty result, and that distinction is load-bearing. A consumer gating with "the required scopes are inside the granted ones" would pass every unclassified write and every denied area on an empty set, because the empty set is inside everything — the fail-closed answer would have read as permission. Callers that want a yes/no should use Permits and never compare scope sets at all.

Never returns nil for a non-empty input; returns an empty slice for an empty one, so a caller rendering JSON gets [] rather than null.

func UnescapeName added in v0.7.11

func UnescapeName(raw string) (string, error)

UnescapeName decodes one URL path segment into the name Azure DevOps routes it as, refusing every spelling the service would route differently from how it reads.

It decodes by hand rather than through url.PathUnescape for one reason: PathUnescape leaves an encoded "/" as a literal slash in its output, which then has to be re-detected, and the two-step version of that rule is exactly where a laundering bug hides. Here the byte is refused where it is decoded.

Types

type Capability

type Capability string

Capability is the ONE access name a classified request needs. The set is closed: a request is one of these or it is refused.

const (
	// CapRead is a read of any area in readAreas: the floor every profile
	// starts from, and a ceiling must include it.
	//
	// It is WIDER than "read the code". The read scope set covers every area
	// the catalogue answers CapRead for, so the minimum Azure DevOps credential
	// can also read service-connection configuration, the organisation's
	// groups and users, and directory identities. That is inherent to minting
	// by capability rather than by area, and the label says so.
	CapRead Capability = "read"
	// CapCodeWrite is a commit, a push or a ref move that no branch policy
	// protects.
	CapCodeWrite Capability = "code_write"
	// CapPR is creating, updating, reviewing or completing a pull request
	// WITHOUT bypassing policy.
	CapPR Capability = "pr"
	// CapPolicyAdmin is editing the branch policies themselves.
	CapPolicyAdmin Capability = "policy_admin"
	// CapPolicyBypass is landing a change THROUGH a policy rather than past
	// it: a PR completed with bypassPolicy, or a ref moved on a protected
	// branch. Azure DevOps asks only for write scope for both, which is the
	// whole reason this capability is spelled separately from CapCodeWrite.
	CapPolicyBypass Capability = "policy_bypass"
	// CapRepoAdmin is creating, renaming or deleting a repository.
	CapRepoAdmin Capability = "repo_admin"
	// CapSecurityAdmin is changing permissions, ACLs or directory identities.
	CapSecurityAdmin Capability = "security_admin"
	// CapServiceEndpointAdmin is changing a service connection — the object
	// that holds someone else's cloud credential.
	CapServiceEndpointAdmin Capability = "serviceendpoint_admin"
	// CapBuildExecute is queueing a pipeline run — and, because the classic
	// release area folds onto the build capabilities in this catalogue,
	// creating, deploying and deleting releases. The label says both.
	CapBuildExecute Capability = "build_execute"
	// CapBuildAdmin is editing a pipeline or release definition — deciding
	// what every future run and release executes.
	CapBuildAdmin Capability = "build_admin"
	// CapWorkWrite is writing work items, including through the batch door.
	CapWorkWrite Capability = "work_write"
	// CapWikiWrite is writing a wiki.
	CapWikiWrite Capability = "wiki_write"
	// CapPackagingWrite is publishing to a feed.
	CapPackagingWrite Capability = "packaging_write"
	// CapProjectAdmin is creating, changing or deleting a project.
	CapProjectAdmin Capability = "project_admin"
)

The GRANTABLE capabilities — the only values a provider row's ceiling or profile may name, and the only ones ScopesFor turns into scopes.

const (
	// CapDeniedTokens is the personal-access-token and token-administration
	// area. A token lane that can mint or revoke tokens is a lane that can
	// leave the lane.
	CapDeniedTokens Capability = "denied_tokens"
	// CapDeniedServiceHooks is the service-hook area: a subscription that
	// forwards the org's events to an address of the caller's choosing is
	// exfiltration with a webhook's paperwork.
	CapDeniedServiceHooks Capability = "denied_service_hooks"
	// CapDeniedExtensions is extension management: installing an extension
	// runs someone else's code inside the organisation.
	CapDeniedExtensions Capability = "denied_extension_management"
	// CapDeniedInternal is the undocumented contribution API the web UI drives
	// itself with (_apis/Contribution/…). It is a single door onto every other
	// area, so no capability over it could mean anything.
	CapDeniedInternal Capability = "denied_internal"
	// CapUnclassifiedWrite is a write this catalogue does not recognize. It is
	// the fail-closed answer, not a capability anyone can hold.
	CapUnclassifiedWrite Capability = "unclassified_write"
	// CapUnclassifiedRead is a read of an area outside readAreas — one whose
	// read no scope in the read set could perform. Refused like its write
	// twin, rather than classified as a read that 403s at the forge.
	CapUnclassifiedRead Capability = "unclassified_read"
)

The REFUSED capabilities. They are Capability values rather than errors so a refusal can name the area it refused — an operator reading "denied_tokens" learns something "refused" does not tell them — but none of them is grantable, so a caller refuses every one.

func GrantableCapabilities

func GrantableCapabilities() []Capability

GrantableCapabilities is the grantable set in a stable order, for the "want one of: …" half of a rejected write and for a UI's picker.

func ProfileContribute

func ProfileContribute() []Capability

ProfileContribute is the "do the work" profile: read, push, pull requests, work items and wiki.

CapBuildExecute is deliberately ABSENT. Queueing a pipeline executes YAML under the pipeline's own identity, which is a different and usually wider identity than the person contributing — so it is an opt-in on the row, never part of what "contribute" means.

func ProfileRead

func ProfileRead() []Capability

ProfileRead is the DEFAULT profile: reads and nothing else. A provider row whose default_profile is empty gets this one, which is what makes the read-only default a fact of the catalogue rather than a field someone remembered to fill in.

func (Capability) Denied

func (c Capability) Denied() bool

Denied reports whether c names an area no capability can be held over.

func (Capability) Grantable

func (c Capability) Grantable() bool

Grantable reports whether c may be written on a provider row and turned into a scope. Everything else — the denied areas and the unclassified write — is refused, which is why a caller tests THIS rather than enumerating refusals.

func (Capability) Valid

func (c Capability) Valid() bool

Valid reports whether c is a value Classify can return at all.

type Request

type Request struct {
	// Method is the HTTP method as it arrived on the request line. An
	// override header may RAISE it — see Classify.
	Method string
	// Host is the request's host WITHOUT a port: a caller holding
	// "dev.azure.com:443" strips the port before it gets here, because a port
	// would make the host match nothing and the request would be refused as
	// not Azure DevOps.
	Host string
	// Path is the request's path, still percent-encoded and WITHOUT the query
	// string. A query left on it would become a final path segment and could
	// only ever make the route read as something it is not.
	Path string
	// Header is the request's headers. May be nil. The keys are compared
	// case-insensitively here, so a map built by hand — or one that preserved
	// the wire spelling instead of canonicalising it — is read the same way a
	// net/http server's is.
	Header http.Header
	// BodyPeek is the first MaxBodyPeek bytes of the body, or nil when there
	// is none. A route that needs the body and cannot see all of it is
	// REFUSED, never classified on the visible prefix.
	BodyPeek []byte
	// Org is the organisation the provider row pinned. REQUIRED: an
	// unpinned request is refused, because "some Azure DevOps organisation"
	// is not a policy.
	Org string
	// RefProtected answers whether one ref is covered by a branch policy. It
	// is given the ref name EXACTLY as the request body spelled it, which on
	// every documented Azure DevOps ref route is the full name
	// ("refs/heads/main") and not a short one ("main"); an oracle whose cache
	// is keyed differently normalizes on its own side.
	//
	// Optional — when nil, a ref move classifies as CapCodeWrite and the
	// caller is handed the ref names in Verdict.Refs so it can re-decide
	// against its own per-run cache.
	RefProtected func(ref string) bool
	// BodyWithheld marks a request whose body the caller has not read. A
	// route whose classification reads the body then fails with ErrNeedsBody
	// rather than classifying, and the caller peeks and asks again. Every
	// other route classifies on its path alone, so a gate can stream a package
	// publish or a wiki attachment through untouched — and the list of routes
	// that need the body is the one Classify walks, not a second copy.
	BodyWithheld bool
}

Request is ONE candidate Azure DevOps request, as much of it as a classifier can be given without buffering the whole body.

It is a struct rather than a parameter list because two of the hazards need more than the wire fields: an organisation to pin the path against, and an oracle for "is this ref protected". Both are properties of the ROW and the RUN, not of the request, and neither can be guessed from the bytes.

type Verdict

type Verdict struct {
	// Capability is the ONE access this request needs.
	Capability Capability
	// Refs are the ref names the request's body named, if any. Always
	// returned for a ref move, whether or not RefProtected was supplied: the
	// caller that keeps the protected-branch cache is the one that can answer
	// cheaply, and it cannot ask about refs it was never told.
	Refs []string
}

Verdict is one classification.

func Classify

func Classify(req Request) (Verdict, error)

Classify names the ONE capability req needs, or refuses req.

It refuses — returns an error — only where no capability could be honest: a host that is not Azure DevOps, an organisation that is not the row's, a path whose structure is hidden behind percent-encoding or dot segments, a method override outside its documented shape, a git-over-HTTP endpoint, or a body the peek cannot see whole. Everything else classifies, and a write it does not recognize classifies as CapUnclassifiedWrite, which is not grantable.

GIT-OVER-HTTP IS OUT OF SCOPE and is refused by name rather than classified. The ref names a push carries live in a pack protocol this catalogue does not parse, so no answer here could tell a clone from a push onto a protected branch; both used to land on CapUnclassifiedWrite, which told a caller nothing about which it had. The transport is gated on its own.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL