diff --git a/packages/code-storage-go/client.go b/packages/code-storage-go/client.go index deeaca0..896e452 100644 --- a/packages/code-storage-go/client.go +++ b/packages/code-storage-go/client.go @@ -470,6 +470,9 @@ func (c *Client) generateJWT(repoID string, options RemoteURLOptions) (string, e "iat": issuedAt.Unix(), "exp": issuedAt.Add(ttl).Unix(), } + if len(options.Refs) > 0 { + claims["refs"] = encodeRefsClaim(options.Refs) + } if len(options.Ops) > 0 { claims["ops"] = options.Ops } diff --git a/packages/code-storage-go/commit.go b/packages/code-storage-go/commit.go index 4b45987..2c81f3b 100644 --- a/packages/code-storage-go/commit.go +++ b/packages/code-storage-go/commit.go @@ -183,7 +183,7 @@ func (b *CommitBuilder) Send(ctx context.Context) (CommitResult, error) { } ttl := resolveCommitTTL(b.options.InvocationOptions, defaultTokenTTL) - jwtToken, err := b.client.generateJWT(b.repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := b.client.generateJWT(b.repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: b.options.Refs}) if err != nil { return CommitResult{}, err } diff --git a/packages/code-storage-go/diff_commit.go b/packages/code-storage-go/diff_commit.go index 8d6146a..b8c4122 100644 --- a/packages/code-storage-go/diff_commit.go +++ b/packages/code-storage-go/diff_commit.go @@ -67,7 +67,7 @@ func (d *diffCommitExecutor) send(ctx context.Context, repoID string) (CommitRes } ttl := resolveCommitTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := d.client.generateJWT(repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := d.client.generateJWT(repoID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return CommitResult{}, err } diff --git a/packages/code-storage-go/jwt_claims.go b/packages/code-storage-go/jwt_claims.go new file mode 100644 index 0000000..a45e4a1 --- /dev/null +++ b/packages/code-storage-go/jwt_claims.go @@ -0,0 +1,14 @@ +package storage + +// encodeRefsClaim builds the JWT `refs` claim as an array of [pattern, ops] tuples. +func encodeRefsClaim(refs RefPolicies) []any { + out := make([]any, len(refs)) + for i, rule := range refs { + opList := []string(rule.Ops) + if opList == nil { + opList = []string{} + } + out[i] = []any{rule.Pattern, opList} + } + return out +} diff --git a/packages/code-storage-go/jwt_claims_test.go b/packages/code-storage-go/jwt_claims_test.go new file mode 100644 index 0000000..98f8bfe --- /dev/null +++ b/packages/code-storage-go/jwt_claims_test.go @@ -0,0 +1,36 @@ +package storage + +import "testing" + +func TestEncodeRefsClaim(t *testing.T) { + encoded := encodeRefsClaim(RefPolicies{ + {Pattern: "refs/heads/main", Ops: Ops{OpNoPush}}, + {Pattern: "refs/heads/release/*", Ops: nil}, + {Pattern: "*", Ops: Ops{OpNoForcePush}}, + }) + + if len(encoded) != 3 { + t.Fatalf("expected 3 rules, got %d", len(encoded)) + } + + mainRule, ok := encoded[0].([]any) + if !ok { + t.Fatalf("unexpected rule type: %T", encoded[0]) + } + if mainRule[0] != "refs/heads/main" { + t.Fatalf("unexpected pattern: %v", mainRule[0]) + } + mainOps, ok := mainRule[1].([]string) + if !ok || len(mainOps) != 1 || mainOps[0] != OpNoPush { + t.Fatalf("unexpected ops: %v", mainRule[1]) + } + + releaseRule, ok := encoded[1].([]any) + if !ok { + t.Fatalf("unexpected rule type: %T", encoded[1]) + } + releaseOps, ok := releaseRule[1].([]string) + if !ok || len(releaseOps) != 0 { + t.Fatalf("expected empty ops slice, got %v", releaseRule[1]) + } +} diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index d55ebea..292d621 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -536,12 +536,12 @@ func (r *Repo) GetNote(ctx context.Context, options GetNoteOptions) (GetNoteResu // CreateNote adds a git note. func (r *Repo) CreateNote(ctx context.Context, options CreateNoteOptions) (NoteWriteResult, error) { - return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author) + return r.writeNote(ctx, options.InvocationOptions, "add", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Refs) } // AppendNote appends to a git note. func (r *Repo) AppendNote(ctx context.Context, options AppendNoteOptions) (NoteWriteResult, error) { - return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author) + return r.writeNote(ctx, options.InvocationOptions, "append", options.SHA, options.Note, options.ExpectedRefSHA, options.Author, options.Refs) } // DeleteNote deletes a git note. @@ -552,7 +552,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return NoteWriteResult{}, err } @@ -592,7 +592,7 @@ func (r *Repo) DeleteNote(ctx context.Context, options DeleteNoteOptions) (NoteW return result, nil } -func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor) (NoteWriteResult, error) { +func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, action string, sha string, note string, expectedRefSHA string, author *NoteAuthor, refs RefPolicies) (NoteWriteResult, error) { sha = strings.TrimSpace(sha) if sha == "" { return NoteWriteResult{}, errors.New("note sha is required") @@ -604,7 +604,7 @@ func (r *Repo) writeNote(ctx context.Context, invocation InvocationOptions, acti } ttl := resolveInvocationTTL(invocation, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: refs}) if err != nil { return NoteWriteResult{}, err } @@ -862,7 +862,7 @@ func (r *Repo) Grep(ctx context.Context, options GrepOptions) (GrepResult, error // PullUpstream triggers a pull-upstream operation. func (r *Repo) PullUpstream(ctx context.Context, options PullUpstreamOptions) error { ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return err } @@ -897,7 +897,7 @@ func (r *Repo) CreateBranch(ctx context.Context, options CreateBranchOptions) (C } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return CreateBranchResult{}, err } @@ -944,7 +944,7 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return DeleteBranchResult{}, err } @@ -1020,7 +1020,7 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return MergeResult{}, err } @@ -1081,7 +1081,7 @@ func (r *Repo) CreateTag(ctx context.Context, options CreateTagOptions) (CreateT } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return CreateTagResult{}, err } @@ -1116,7 +1116,7 @@ func (r *Repo) DeleteTag(ctx context.Context, options DeleteTagOptions) (DeleteT } ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead, PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return DeleteTagResult{}, err } @@ -1159,7 +1159,7 @@ func (r *Repo) RestoreCommit(ctx context.Context, options RestoreCommitOptions) } ttl := resolveCommitTTL(options.InvocationOptions, defaultTokenTTL) - jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl}) + jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitWrite}, TTL: ttl, Refs: options.Refs}) if err != nil { return RestoreCommitResult{}, err } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 7427cff..c845b5e 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -104,6 +104,56 @@ func TestRemoteURLOps(t *testing.T) { }) } +func TestRemoteURLRefs(t *testing.T) { + client, err := NewClient(Options{Name: "acme", Key: testKey, StorageBaseURL: "acme.code.storage"}) + if err != nil { + t.Fatalf("client error: %v", err) + } + repo := &Repo{ID: "repo-1", DefaultBranch: "main", client: client} + + t.Run("includes refs in JWT when provided", func(t *testing.T) { + remote, err := repo.RemoteURL(nil, RemoteURLOptions{ + Refs: RefPolicies{ + {Pattern: "refs/heads/main", Ops: Ops{OpNoPush}}, + {Pattern: "*", Ops: Ops{OpNoForcePush}}, + }, + }) + if err != nil { + t.Fatalf("remote url error: %v", err) + } + claims := parseJWTFromURL(t, remote) + refs, ok := claims["refs"].([]interface{}) + if !ok { + t.Fatalf("expected refs claim to be a list, got %T", claims["refs"]) + } + if len(refs) != 2 { + t.Fatalf("expected 2 ref rules, got %d", len(refs)) + } + mainRule, ok := refs[0].([]interface{}) + if !ok || len(mainRule) != 2 { + t.Fatalf("unexpected main rule shape: %v", refs[0]) + } + if mainRule[0] != "refs/heads/main" { + t.Fatalf("unexpected pattern: %v", mainRule[0]) + } + mainOps, ok := mainRule[1].([]interface{}) + if !ok || len(mainOps) != 1 || mainOps[0] != "no-push" { + t.Fatalf("unexpected main ops: %v", mainRule[1]) + } + }) + + t.Run("omits refs from JWT when not provided", func(t *testing.T) { + remote, err := repo.RemoteURL(nil, RemoteURLOptions{}) + if err != nil { + t.Fatalf("remote url error: %v", err) + } + claims := parseJWTFromURL(t, remote) + if _, ok := claims["refs"]; ok { + t.Fatalf("expected no refs claim") + } + }) +} + func TestListFilesEphemeral(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/files" { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 0edc0dc..208e21c 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -35,16 +35,28 @@ type Op = string const ( OpNoForcePush Op = "no-force-push" + OpNoPush Op = "no-push" ) // Ops is a list of policy operations. type Ops []Op +// RefPolicy is a single ordered ref-matching policy rule (first match wins). +type RefPolicy struct { + Pattern string + Ops Ops +} + +// RefPolicies is an ordered list of per-ref policy rules for the JWT `refs` claim. +type RefPolicies []RefPolicy + // RemoteURLOptions configure token generation for remote URLs. type RemoteURLOptions struct { Permissions []Permission TTL time.Duration Ops Ops + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // InvocationOptions holds common request options. @@ -232,6 +244,8 @@ type ArchiveOptions struct { type PullUpstreamOptions struct { InvocationOptions Ref string + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // ListFilesOptions configures list files. @@ -309,6 +323,8 @@ type CreateBranchOptions struct { TargetBranch string BaseIsEphemeral bool TargetIsEphemeral bool + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // CreateBranchResult describes branch creation result. @@ -324,6 +340,8 @@ type DeleteBranchOptions struct { InvocationOptions Name string Ephemeral *bool + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // DeleteBranchResult describes branch deletion result. @@ -357,6 +375,8 @@ type MergeOptions struct { AllowUnrelatedHistories bool // Squash is incompatible with MergeStrategyFFOnly. Squash bool + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // MergeResultStatus describes a merge operation outcome. @@ -422,6 +442,8 @@ type CreateTagOptions struct { InvocationOptions Name string Target string + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // CreateTagResult describes tag creation result. @@ -435,6 +457,8 @@ type CreateTagResult struct { type DeleteTagOptions struct { InvocationOptions Name string + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // DeleteTagResult describes tag deletion result. @@ -544,6 +568,8 @@ type CreateNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // AppendNoteOptions configures note append. @@ -553,6 +579,8 @@ type AppendNoteOptions struct { Note string ExpectedRefSHA string Author *NoteAuthor + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // DeleteNoteOptions configures note delete. @@ -561,6 +589,8 @@ type DeleteNoteOptions struct { SHA string ExpectedRefSHA string Author *NoteAuthor + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // NoteWriteResult describes note write response. @@ -799,6 +829,8 @@ type CommitOptions struct { EphemeralBase bool Author CommitSignature Committer *CommitSignature + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // CommitFromDiffOptions configures diff commit. @@ -813,6 +845,8 @@ type CommitFromDiffOptions struct { EphemeralBase bool Author CommitSignature Committer *CommitSignature + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // RestoreCommitOptions configures restore commit. @@ -824,6 +858,8 @@ type RestoreCommitOptions struct { ExpectedHeadSHA string Author CommitSignature Committer *CommitSignature + // Refs is evaluated in declaration order; the first matching rule wins. + Refs RefPolicies } // RestoreCommitResult describes restore commit. diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index 3a2ef0d..e00ce03 100644 --- a/packages/code-storage-go/version.go +++ b/packages/code-storage-go/version.go @@ -2,7 +2,7 @@ package storage const ( PackageName = "code-storage-go-sdk" - PackageVersion = "0.8.0" + PackageVersion = "0.9.0" ) func userAgent() string { diff --git a/packages/code-storage-python/pierre_storage/__init__.py b/packages/code-storage-python/pierre_storage/__init__.py index f895975..6b5e5a7 100644 --- a/packages/code-storage-python/pierre_storage/__init__.py +++ b/packages/code-storage-python/pierre_storage/__init__.py @@ -3,7 +3,7 @@ A Python SDK for interacting with Pierre's git storage system. """ -from pierre_storage.auth import generate_jwt +from pierre_storage.auth import encode_refs_claim, generate_jwt from pierre_storage.client import GitStorage, create_client from pierre_storage.errors import ApiError, RefUpdateError from pierre_storage.types import ( @@ -31,7 +31,10 @@ GitStorageOptions, Op, OP_NO_FORCE_PUSH, + OP_NO_PUSH, Ops, + RefPolicy, + Refs, GrepFileMatch, GrepLine, GrepResult, @@ -64,6 +67,7 @@ "GitStorage", "create_client", # Auth + "encode_refs_claim", "generate_jwt", # Errors "ApiError", @@ -96,7 +100,10 @@ "GitStorageOptions", "Op", "OP_NO_FORCE_PUSH", + "OP_NO_PUSH", "Ops", + "RefPolicy", + "Refs", "ListBranchesResult", "ListCommitsResult", "ListFilesResult", diff --git a/packages/code-storage-python/pierre_storage/auth.py b/packages/code-storage-python/pierre_storage/auth.py index e3c8287..f8f69a8 100644 --- a/packages/code-storage-python/pierre_storage/auth.py +++ b/packages/code-storage-python/pierre_storage/auth.py @@ -1,11 +1,23 @@ """JWT authentication utilities for Pierre Git Storage SDK.""" import time -from typing import List, Optional +from typing import List, Optional, Union import jwt from cryptography.hazmat.primitives import serialization +from pierre_storage.types import Refs + + +def encode_refs_claim(refs: Refs) -> List[List[Union[str, List[str]]]]: + """Encode per-ref policy rules for the JWT ``refs`` claim.""" + out: List[List[Union[str, List[str]]]] = [] + for rule in refs: + pattern = rule["pattern"] + ops = rule.get("ops") + out.append([pattern, list(ops) if ops is not None else []]) + return out + def generate_jwt( key_pem: str, @@ -14,6 +26,7 @@ def generate_jwt( scopes: Optional[List[str]] = None, ttl: int = 31536000, # 1 year default ops: Optional[List[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Generate a JWT token for Git storage authentication. @@ -24,6 +37,7 @@ def generate_jwt( scopes: List of permission scopes (defaults to ['git:write', 'git:read']) ttl: Time-to-live in seconds (defaults to 1 year) ops: List of policy operations (e.g., ['no-force-push']) + refs: Ordered per-ref policy rules (first match wins) Returns: Signed JWT token string @@ -43,6 +57,8 @@ def generate_jwt( "iat": now, "exp": now + ttl, } + if refs: + payload["refs"] = encode_refs_claim(refs) if ops: payload["ops"] = ops diff --git a/packages/code-storage-python/pierre_storage/client.py b/packages/code-storage-python/pierre_storage/client.py index 236037c..b7d24fa 100644 --- a/packages/code-storage-python/pierre_storage/client.py +++ b/packages/code-storage-python/pierre_storage/client.py @@ -618,6 +618,7 @@ def _generate_jwt( permissions = ["git:write", "git:read"] ttl: int = 31536000 # 1 year default ops: Optional[List[str]] = None + refs = None if options: if "permissions" in options: @@ -628,6 +629,8 @@ def _generate_jwt( ttl = option_ttl if "ops" in options: ops = options["ops"] + if "refs" in options: + refs = options["refs"] elif "default_ttl" in self.options: default_ttl = self.options["default_ttl"] if isinstance(default_ttl, int): @@ -640,6 +643,7 @@ def _generate_jwt( permissions, ttl, ops, + refs, ) diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 0426d9f..edc19c8 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -50,6 +50,7 @@ NoteReadResult, NoteWriteResult, RefUpdate, + Refs, RestoreCommitResult, TagInfo, ) @@ -59,6 +60,18 @@ ZERO_DATETIME_UTC = datetime.min.replace(tzinfo=timezone.utc) +def _build_jwt_options( + permissions: List[str], + ttl: int, + refs: Optional[Refs] = None, +) -> Dict[str, Any]: + """Assemble the JWT options dict, attaching ref policies when supplied.""" + options: Dict[str, Any] = {"permissions": permissions, "ttl": ttl} + if refs: + options["refs"] = refs + return options + + class StreamingResponse: """Stream wrapper that keeps the HTTP client alive until closed.""" @@ -208,6 +221,7 @@ async def get_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get remote URL for Git operations. @@ -215,6 +229,7 @@ async def get_remote_url( permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds ops: List of policy operations (e.g., ["no-force-push"]) + refs: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT @@ -226,6 +241,8 @@ async def get_remote_url( options["ttl"] = ttl if ops is not None: options["ops"] = ops + if refs is not None: + options["refs"] = refs jwt_token = self.generate_jwt(self._id, options if options else None) url = f"https://t:{jwt_token}@{self.storage_base_url}/{self._id}.git" @@ -237,6 +254,7 @@ async def get_import_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get import remote URL for Git operations. @@ -244,11 +262,14 @@ async def get_import_remote_url( permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds ops: List of policy operations (e.g., ["no-force-push"]) + refs: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT pointing to import namespace """ - url = await self.get_remote_url(permissions=permissions, ttl=ttl, ops=ops) + url = await self.get_remote_url( + permissions=permissions, ttl=ttl, ops=ops, refs=refs + ) return url.replace(".git", "+import.git") async def get_ephemeral_remote_url( @@ -257,6 +278,7 @@ async def get_ephemeral_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get ephemeral remote URL for Git operations. @@ -264,11 +286,14 @@ async def get_ephemeral_remote_url( permissions: List of permissions (e.g., ["git:write", "git:read"]) ttl: Token TTL in seconds ops: List of policy operations (e.g., ["no-force-push"]) + refs: Ordered per-ref policy rules (first match wins) Returns: Git remote URL with embedded JWT pointing to ephemeral namespace """ - url = await self.get_remote_url(permissions=permissions, ttl=ttl, ops=ops) + url = await self.get_remote_url( + permissions=permissions, ttl=ttl, ops=ops, refs=refs + ) return url.replace(".git", "+ephemeral.git") async def get_file_stream( @@ -564,6 +589,7 @@ async def create_branch( base_is_ephemeral: bool = False, target_is_ephemeral: bool = False, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> CreateBranchResult: """Create or promote a branch. @@ -595,7 +621,7 @@ async def create_branch( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, refs), ) payload: Dict[str, Any] = { @@ -628,6 +654,8 @@ async def create_branch( error_data = response.json() if isinstance(error_data, dict) and error_data.get("message"): message = str(error_data["message"]) + elif isinstance(error_data, dict) and error_data.get("error"): + message = str(error_data["error"]) else: message = f"{message} with HTTP {response.status_code}" except Exception: @@ -652,6 +680,7 @@ async def delete_branch( name: str, ephemeral: Optional[bool] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> DeleteBranchResult: """Delete a branch. @@ -667,7 +696,7 @@ async def delete_branch( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/branches" @@ -725,6 +754,7 @@ async def merge( allow_unrelated_histories: Optional[bool] = None, squash: Optional[bool] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> MergeBranchesResult: """Merge a source branch into a target branch.""" source_branch_clean = source_branch.strip() @@ -777,7 +807,7 @@ async def merge( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/merge" @@ -885,6 +915,7 @@ async def create_tag( name: str, target: str, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> CreateTagResult: """Create a tag.""" name_clean = name.strip() @@ -900,7 +931,7 @@ async def create_tag( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, refs), ) payload = {"name": name_clean, "target": target_clean} @@ -944,6 +975,7 @@ async def delete_tag( *, name: str, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> DeleteTagResult: """Delete a tag.""" name_clean = name.strip() @@ -955,7 +987,7 @@ async def delete_tag( ttl_value = resolve_invocation_ttl_seconds({"ttl": ttl} if ttl is not None else None) jwt = self.generate_jwt( self._id, - {"permissions": ["git:read", "git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:read", "git:write"], ttl_value, refs), ) url = f"{self.api_base_url}/api/v{self.api_version}/repos/tags" @@ -1276,6 +1308,7 @@ async def create_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> NoteWriteResult: """Create a git note.""" return await self._write_note( @@ -1286,6 +1319,7 @@ async def create_note( expected_ref_sha=expected_ref_sha, author=author, ttl=ttl, + refs=refs, ) async def append_note( @@ -1296,6 +1330,7 @@ async def append_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> NoteWriteResult: """Append to a git note.""" return await self._write_note( @@ -1306,6 +1341,7 @@ async def append_note( expected_ref_sha=expected_ref_sha, author=author, ttl=ttl, + refs=refs, ) async def delete_note( @@ -1315,6 +1351,7 @@ async def delete_note( expected_ref_sha: Optional[str] = None, author: Optional[CommitSignature] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> NoteWriteResult: """Delete a git note.""" sha_clean = sha.strip() @@ -1322,7 +1359,7 @@ async def delete_note( raise ValueError("delete_note sha is required") ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl}) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) payload: Dict[str, Any] = {"sha": sha_clean} if expected_ref_sha and expected_ref_sha.strip(): @@ -1633,18 +1670,21 @@ async def pull_upstream( *, ref: Optional[str] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> None: """Pull from upstream repository. Args: ref: Git ref to pull ttl: Token TTL in seconds + ops: Repo-wide policy ops + refs: Ordered per-ref policy rules (first match wins) Raises: ApiError: If pull fails """ ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl}) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) body = {} if ref: @@ -1678,6 +1718,7 @@ async def restore_commit( expected_head_sha: Optional[str] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> RestoreCommitResult: """Restore a previous commit. @@ -1713,7 +1754,7 @@ async def restore_commit( raise ValueError("restoreCommit author name and email are required") ttl = ttl or resolve_commit_ttl_seconds(None) - jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl}) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) metadata: Dict[str, Any] = { "target_branch": target_branch, @@ -1817,6 +1858,7 @@ def create_commit( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> CommitBuilder: """Create a new commit builder. @@ -1856,7 +1898,7 @@ def create_commit( def get_auth_token() -> str: return self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl}, + _build_jwt_options(["git:write"], ttl, refs), ) return CommitBuilderImpl( @@ -1879,6 +1921,7 @@ async def create_commit_from_diff( ephemeral_base: Optional[bool] = None, committer: Optional[CommitSignature] = None, ttl: Optional[int] = None, + refs: Optional[Refs] = None, ) -> CommitResult: """Create a commit by applying a unified diff.""" if diff is None: @@ -1906,7 +1949,7 @@ async def create_commit_from_diff( def get_auth_token() -> str: return self.generate_jwt( self._id, - {"permissions": ["git:write"], "ttl": ttl_value}, + _build_jwt_options(["git:write"], ttl_value, refs), ) return await send_diff_commit_request( @@ -1927,6 +1970,7 @@ async def _write_note( expected_ref_sha: Optional[str], author: Optional[CommitSignature], ttl: Optional[int], + refs: Optional[Refs] = None, ) -> NoteWriteResult: sha_clean = sha.strip() if not sha_clean: @@ -1937,7 +1981,7 @@ async def _write_note( raise ValueError(f"{action_label} note is required") ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS - jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl}) + jwt = self.generate_jwt(self._id, _build_jwt_options(["git:write"], ttl, refs)) payload: Dict[str, Any] = { "sha": sha_clean, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 6af1573..b5f3671 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -45,11 +45,22 @@ class GitStorageOptions(TypedDict, total=False): Op = str OP_NO_FORCE_PUSH: Op = "no-force-push" +OP_NO_PUSH: Op = "no-push" # Ops is a list of policy operations. Ops = List[Op] +class RefPolicy(TypedDict, total=False): + """A single ordered ref-matching policy rule (first match wins).""" + + pattern: str # required + ops: List[Op] + + +Refs = List[RefPolicy] + + class PublicGitHubBaseRepoAuth(TypedDict): """Authentication mode for GitHub base repositories.""" @@ -609,6 +620,7 @@ async def get_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get the remote URL for the repository.""" ... @@ -619,6 +631,7 @@ async def get_import_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get the import remote URL for the repository.""" ... @@ -629,6 +642,7 @@ async def get_ephemeral_remote_url( permissions: Optional[list[str]] = None, ttl: Optional[int] = None, ops: Optional[list[str]] = None, + refs: Optional[Refs] = None, ) -> str: """Get the ephemeral remote URL for the repository.""" ... diff --git a/packages/code-storage-python/pierre_storage/version.py b/packages/code-storage-python/pierre_storage/version.py index c678598..ac3cde2 100644 --- a/packages/code-storage-python/pierre_storage/version.py +++ b/packages/code-storage-python/pierre_storage/version.py @@ -1,7 +1,7 @@ """Version information for Pierre Storage SDK.""" PACKAGE_NAME = "code-storage-py-sdk" -PACKAGE_VERSION = "1.9.0" +PACKAGE_VERSION = "1.10.0" def get_user_agent() -> str: diff --git a/packages/code-storage-python/pyproject.toml b/packages/code-storage-python/pyproject.toml index 83d9466..1eab32e 100644 --- a/packages/code-storage-python/pyproject.toml +++ b/packages/code-storage-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pierre-storage" -version = "1.9.0" +version = "1.10.0" description = "Pierre Git Storage SDK for Python" readme = "README.md" license = "MIT" diff --git a/packages/code-storage-python/tests/ref_policies_live.py b/packages/code-storage-python/tests/ref_policies_live.py new file mode 100644 index 0000000..4859bec --- /dev/null +++ b/packages/code-storage-python/tests/ref_policies_live.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Live ref-policy smoke test against a running local git3p stack.""" + +from __future__ import annotations + +import asyncio +import os +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path + +import jwt + +from pierre_storage import OP_NO_PUSH, create_client + +def _default_key_path() -> Path: + if env := os.environ.get("GIT3P_KEY_PATH"): + return Path(env) + here = Path(__file__).resolve() + candidates = [ + here.parents[3] / "git3p-backend/hack/test-scripts/dev-keys/private.pem", + here.parents[4] / "monorepo/git3p-backend/hack/test-scripts/dev-keys/private.pem", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[-1] + + +KEY_PATH = _default_key_path() + +API_BASE = os.environ.get("GIT3P_API_URL", "http://127.0.0.1:8081") +STORAGE_HOST = os.environ.get("GIT3P_GIT_URL", "127.0.0.1:8080").replace("http://", "").replace("https://", "") +ISSUER = os.environ.get("GIT3P_ISSUER", "local") + + +def git(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + text=True, + capture_output=True, + env={ + **os.environ, + "GIT_TERMINAL_PROMPT": "0", + "GIT_CONFIG_NOSYSTEM": "1", + "HOME": str(cwd), + }, + check=False, + ) + + +def build_git_remote(repo_id: str, token: str) -> str: + """Build a Git remote URL (HTTP for local dev; JWT may contain '@').""" + host = STORAGE_HOST + if "://" in host: + base = host.rstrip("/") + elif host.startswith("127.0.0.1") or host.startswith("localhost"): + base = f"http://{host}" + else: + base = f"https://{host}" + return f"{base}/{repo_id}.git".replace("://", f"://t:{token}@", 1) + + +def decode_refs(token: str) -> list: + payload = jwt.decode(token, options={"verify_signature": False}) + return payload.get("refs", []) + + +async def main() -> int: + if not KEY_PATH.exists(): + print(f"FAIL: signing key not found at {KEY_PATH}", file=sys.stderr) + return 1 + + key_pem = KEY_PATH.read_text() + repo_id = f"sdk-refpol-py-{int(time.time())}-{uuid.uuid4().hex[:6]}" + + client = create_client( + { + "name": ISSUER, + "key": key_pem, + "api_base_url": API_BASE, + "storage_base_url": STORAGE_HOST, + } + ) + + print(f"▶ Python ref-policy live test (repo={repo_id})") + repo = await client.create_repo(id=repo_id, default_branch="main") + print(" ✓ repo created") + + open_url = await repo.get_remote_url(permissions=["git:read", "git:write"], ttl=1800) + restricted_url = await repo.get_remote_url( + permissions=["git:read", "git:write"], + ttl=600, + refs=[ + {"pattern": "refs/heads/main", "ops": [OP_NO_PUSH]}, + ], + ) + + open_token = open_url.split("://", 1)[1].rsplit("@", 1)[0].split(":", 1)[1] + restricted_token = restricted_url.split("://", 1)[1].rsplit("@", 1)[0].split(":", 1)[1] + refs_claim = decode_refs(restricted_token) + if refs_claim != [["refs/heads/main", ["no-push"]]]: + print(f"FAIL: unexpected refs claim in JWT: {refs_claim}", file=sys.stderr) + return 1 + print(f" ✓ JWT refs claim: {refs_claim}") + + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + for cmd in ( + ["init", "-b", "main"], + ["config", "user.email", "refpol@pierre.invalid"], + ["config", "user.name", "RefPol Live"], + ["config", "commit.gpgsign", "false"], + ): + git(*cmd, cwd=work) + + (work / "README.md").write_text("hello\n") + git("add", "README.md", cwd=work) + git("commit", "-m", "initial", cwd=work) + + git("remote", "add", "origin", build_git_remote(repo.id, open_token), cwd=work) + push = git("push", "-u", "origin", "main", cwd=work) + if push.returncode != 0: + print(f"FAIL: seed push failed:\n{push.stdout}{push.stderr}", file=sys.stderr) + return 1 + print(" ✓ seeded main via open token") + + git("checkout", "-b", "feature/allowed", cwd=work) + (work / "README.md").write_text("hello\nfeature\n") + git("add", "README.md", cwd=work) + git("commit", "-m", "feature commit", cwd=work) + + git("remote", "set-url", "origin", build_git_remote(repo.id, restricted_token), cwd=work) + feature_push = git("push", "-u", "origin", "feature/allowed", cwd=work) + if feature_push.returncode != 0: + print( + f"FAIL: feature push should succeed:\n{feature_push.stdout}{feature_push.stderr}", + file=sys.stderr, + ) + return 1 + print(" ✓ feature branch push allowed") + + git("checkout", "main", cwd=work) + (work / "README.md").write_text("hello\nblocked\n") + git("add", "README.md", cwd=work) + git("commit", "-m", "main blocked attempt", cwd=work) + main_push = git("push", "origin", "main", cwd=work) + combined = f"{main_push.stdout}{main_push.stderr}" + if main_push.returncode == 0: + print("FAIL: push to main should be denied by no-push policy", file=sys.stderr) + return 1 + if "denied by policy" not in combined: + print(f"FAIL: expected 'denied by policy' in push output:\n{combined}", file=sys.stderr) + return 1 + print(" ✓ main push denied by policy") + + try: + await client.delete_repo(id=repo_id) + print(" ✓ repo deleted") + except Exception as exc: # noqa: BLE001 + print(f" (cleanup warning: {exc})") + + print("✅ Python ref-policy live test passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/packages/code-storage-python/tests/test_client.py b/packages/code-storage-python/tests/test_client.py index 1a38dd5..2434aa2 100644 --- a/packages/code-storage-python/tests/test_client.py +++ b/packages/code-storage-python/tests/test_client.py @@ -1133,6 +1133,36 @@ def test_generate_jwt_with_empty_ops(self, test_key: str) -> None: payload = jwt.decode(token, options={"verify_signature": False}) assert "ops" not in payload + def test_generate_jwt_with_refs(self, test_key: str) -> None: + """Test JWT generation with per-ref policy rules.""" + token = generate_jwt( + key_pem=test_key, + issuer="test-customer", + repo_id="test-repo", + refs=[ + {"pattern": "refs/heads/main", "ops": ["no-push"]}, + {"pattern": "*", "ops": ["no-force-push"]}, + ], + ) + + payload = jwt.decode(token, options={"verify_signature": False}) + assert payload["refs"] == [ + ["refs/heads/main", ["no-push"]], + ["*", ["no-force-push"]], + ] + assert "ops" not in payload + + def test_generate_jwt_without_refs(self, test_key: str) -> None: + """Test JWT generation omits refs when not provided.""" + token = generate_jwt( + key_pem=test_key, + issuer="test-customer", + repo_id="test-repo", + ) + + payload = jwt.decode(token, options={"verify_signature": False}) + assert "refs" not in payload + def test_generate_jwt_default_ttl(self, test_key: str) -> None: """Test JWT generation uses 1 year default TTL.""" token = generate_jwt( diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 52228e6..897fb81 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.8.0", + "version": "1.9.0", "description": "Pierre Git Storage SDK", "repository": { "type": "git", diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index cfcd87f..7caa3fd 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -4,6 +4,7 @@ * A TypeScript SDK for interacting with Pierre's git storage system */ import { SignJWT, importPKCS8 } from 'jose'; +import { encodeRefsClaim } from './jwt_claims'; import snakecaseKeys from 'snakecase-keys'; import { @@ -143,6 +144,7 @@ export { RefUpdateError } from './errors'; export { ApiError } from './fetch'; // Import additional types from types.ts export * from './types'; +export { encodeRefsClaim } from './jwt_claims'; // Export webhook validation utilities export { @@ -1099,6 +1101,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const body = buildNoteWriteBody(sha, note, 'add', { @@ -1144,6 +1147,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const body = buildNoteWriteBody(sha, note, 'append', { @@ -1184,6 +1188,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const body: Record = { @@ -1392,6 +1397,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const body: Record = {}; @@ -1431,6 +1437,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const body: Record = { @@ -1472,6 +1479,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const body: Record = { name }; @@ -1513,6 +1521,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const body: Record = { @@ -1589,6 +1598,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const response = await this.api.post( @@ -1612,6 +1622,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:read', 'git:write'], ttl, + refs: options.refs, }); const response = await this.api.delete( @@ -1651,6 +1662,7 @@ class RepoImpl implements Repo { const jwt = await this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); const metadata: Record = { @@ -1728,6 +1740,7 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); return createCommitBuilder({ @@ -1754,6 +1767,7 @@ class RepoImpl implements Repo { this.generateJWT(this.id, { permissions: ['git:write'], ttl, + refs: options.refs, }); return sendCommitFromDiff({ @@ -2134,6 +2148,9 @@ export class GitStorage { scopes: permissions, iat: now, exp: now + ttl, + ...(options?.refs && options.refs.length > 0 + ? { refs: encodeRefsClaim(options.refs) } + : {}), ...(options?.ops && options.ops.length > 0 ? { ops: options.ops } : {}), }; diff --git a/packages/code-storage-typescript/src/jwt_claims.ts b/packages/code-storage-typescript/src/jwt_claims.ts new file mode 100644 index 0000000..fa7ba3d --- /dev/null +++ b/packages/code-storage-typescript/src/jwt_claims.ts @@ -0,0 +1,6 @@ +import type { RefPolicies } from './types'; + +/** Encode per-ref policy rules for the JWT `refs` claim (array of [pattern, ops] tuples). */ +export function encodeRefsClaim(refs: RefPolicies): [string, string[]][] { + return refs.map(({ pattern, ops }) => [pattern, ops ?? []]); +} diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index b09f099..227f825 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -50,12 +50,30 @@ export type Op = string; export const OP_NO_FORCE_PUSH: Op = 'no-force-push'; +export const OP_NO_PUSH: Op = 'no-push'; + /** A list of policy operations. */ export type Ops = Op[]; -export interface GetRemoteURLOptions { +/** A single ordered ref-matching policy rule (first match wins). */ +export interface RefPolicy { + pattern: string; + ops?: Ops; +} + +/** Ordered per-ref policy rules for the JWT `refs` claim. */ +export type RefPolicies = RefPolicy[]; + +/** Optional per-ref policies that can be attached to a minted JWT for any ref-mutating call. */ +export interface PolicyOptions { + /** Per-ref policy rules evaluated in declaration order (first match wins). */ + refs?: RefPolicies; +} + +export interface GetRemoteURLOptions extends PolicyOptions { permissions?: ('git:write' | 'git:read' | 'repo:write' | 'org:read')[]; ttl?: number; + /** Repo-wide policy ops (legacy; folded into a catch-all `*` rule on verify). */ ops?: Ops; } @@ -262,7 +280,7 @@ export interface ArchiveOptions extends GitStorageInvocationOptions { archivePrefix?: string; } -export interface PullUpstreamOptions extends GitStorageInvocationOptions { +export interface PullUpstreamOptions extends GitStorageInvocationOptions, PolicyOptions { ref?: string; } @@ -335,7 +353,7 @@ export interface ListBranchesResult { } // Create Branch API types -export interface CreateBranchOptions extends GitStorageInvocationOptions { +export interface CreateBranchOptions extends GitStorageInvocationOptions, PolicyOptions { baseRef?: string; /** @deprecated Use baseRef instead. */ baseBranch?: string; @@ -353,7 +371,7 @@ export interface CreateBranchResult { commitSha?: string; } -export interface DeleteBranchOptions extends GitStorageInvocationOptions { +export interface DeleteBranchOptions extends GitStorageInvocationOptions, PolicyOptions { name: string; ephemeral?: boolean; } @@ -387,7 +405,7 @@ export interface ListTagsResult { hasMore: boolean; } -export interface CreateTagOptions extends GitStorageInvocationOptions { +export interface CreateTagOptions extends GitStorageInvocationOptions, PolicyOptions { name: string; target: string; } @@ -400,7 +418,7 @@ export interface CreateTagResult { message: string; } -export interface DeleteTagOptions extends GitStorageInvocationOptions { +export interface DeleteTagOptions extends GitStorageInvocationOptions, PolicyOptions { name: string; } @@ -499,7 +517,7 @@ export interface GetNoteResult { refSha: string; } -interface NoteWriteBaseOptions extends GitStorageInvocationOptions { +interface NoteWriteBaseOptions extends GitStorageInvocationOptions, PolicyOptions { sha: string; note: string; expectedRefSha?: string; @@ -510,7 +528,7 @@ export type CreateNoteOptions = NoteWriteBaseOptions; export type AppendNoteOptions = NoteWriteBaseOptions; -export interface DeleteNoteOptions extends GitStorageInvocationOptions { +export interface DeleteNoteOptions extends GitStorageInvocationOptions, PolicyOptions { sha: string; expectedRefSha?: string; author?: CommitSignature; @@ -669,7 +687,7 @@ export interface FileDiff extends DiffFileBase { export interface FilteredFile extends DiffFileBase {} -interface CreateCommitBaseOptions extends GitStorageInvocationOptions { +interface CreateCommitBaseOptions extends GitStorageInvocationOptions, PolicyOptions { commitMessage: string; expectedHeadSha?: string; baseBranch?: string; @@ -772,7 +790,7 @@ export interface CommitBuilder { export type DiffSource = CommitFileSource; export interface CreateCommitFromDiffOptions - extends GitStorageInvocationOptions { + extends GitStorageInvocationOptions, PolicyOptions { targetBranch: string; commitMessage: string; diff: DiffSource; @@ -821,7 +839,7 @@ export type MergeResultLabel = | 'no_op' | 'unknown'; -export interface MergeOptions extends GitStorageInvocationOptions { +export interface MergeOptions extends GitStorageInvocationOptions, PolicyOptions { sourceBranch: string; sourceIsEphemeral?: boolean; targetBranch: string; @@ -863,7 +881,7 @@ export interface MergeResult { promotedCommits: number; } -export interface RestoreCommitOptions extends GitStorageInvocationOptions { +export interface RestoreCommitOptions extends GitStorageInvocationOptions, PolicyOptions { targetBranch: string; targetCommitSha: string; commitMessage?: string; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index fb99cb4..ba742a6 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -2545,6 +2545,58 @@ describe('GitStorage', () => { expect(payload).not.toHaveProperty('ops'); }); + it('should include refs in JWT when provided', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + + const url = await repo.getRemoteURL({ + refs: [ + { pattern: 'refs/heads/main', ops: ['no-push'] }, + { pattern: '*', ops: ['no-force-push'] }, + ], + }); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload.refs).toEqual([ + ['refs/heads/main', ['no-push']], + ['*', ['no-force-push']], + ]); + expect(payload).not.toHaveProperty('ops'); + }); + + it('should encode allow-rules with empty ops array in refs claim', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + + const url = await repo.getRemoteURL({ + refs: [ + { pattern: 'refs/heads/release/*', ops: [] }, + { pattern: '*', ops: ['no-push'] }, + ], + }); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload.refs).toEqual([ + ['refs/heads/release/*', []], + ['*', ['no-push']], + ]); + }); + + it('should not include refs in JWT when not provided', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({}); + const url = await repo.getRemoteURL(); + + const jwt = extractJWT(url); + const payload = decodeJwtPayload(jwt); + + expect(payload).not.toHaveProperty('refs'); + }); + it('should include repo ID in URL path and JWT payload', async () => { const store = new GitStorage({ name: 'v0', key }); const customRepoId = 'my-custom-repo'; diff --git a/packages/code-storage-typescript/tests/ref-policies-live.mjs b/packages/code-storage-typescript/tests/ref-policies-live.mjs new file mode 100644 index 0000000..4c48b1c --- /dev/null +++ b/packages/code-storage-typescript/tests/ref-policies-live.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +/** + * Live ref-policy smoke test against a running local git3p stack. + * Patches JWT signing for RS256 dev keys (same as full-workflow.js). + */ + +import { SignJWT, importPKCS8 } from 'jose'; +import { createPrivateKey } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const defaultKeyPath = path.resolve( + __dirname, + '../../../git3p-backend/hack/test-scripts/dev-keys/private.pem' +); +const monorepoKeyPath = path.resolve( + __dirname, + '../../../../monorepo/git3p-backend/hack/test-scripts/dev-keys/private.pem' +); + +const keyPath = process.env.GIT3P_KEY_PATH + ? path.resolve(process.env.GIT3P_KEY_PATH) + : existsSync(defaultKeyPath) + ? defaultKeyPath + : monorepoKeyPath; + +const apiBaseUrl = process.env.GIT3P_API_URL ?? 'http://127.0.0.1:8081'; +const storageBaseUrl = (process.env.GIT3P_GIT_URL ?? '127.0.0.1:8080') + .replace(/^https?:\/\//, ''); +const issuer = process.env.GIT3P_ISSUER ?? 'local'; +const keyId = process.env.GIT3P_KEY_ID ?? 'dev-key-001'; + +function decodeJwtPayload(token) { + const part = token.split('.')[1]; + return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); +} + +async function resolveSigningKey(pem) { + const keyObject = createPrivateKey({ key: pem, format: 'pem' }); + const alg = keyObject.asymmetricKeyType === 'rsa' ? 'RS256' : 'ES256'; + return { key: await importPKCS8(pem, alg), alg }; +} + +function patchGitStorage(GitStorage) { + GitStorage.prototype.generateJWT = async function patchedGenerateJWT( + repoId, + options + ) { + const permissions = options?.permissions ?? ['git:write', 'git:read']; + const ttl = options?.ttl ?? 365 * 24 * 60 * 60; + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: this.options.name, + sub: '@pierre/storage', + repo: repoId, + scopes: permissions, + iat: now, + exp: now + ttl, + ...(options?.refs?.length ? { refs: encodeRefsClaim(options.refs) } : {}), + ...(options?.ops?.length ? { ops: options.ops } : {}), + }; + const { key, alg } = await resolveSigningKey(this.options.key); + const header = { alg, typ: 'JWT', kid: keyId }; + return new SignJWT(payload).setProtectedHeader(header).sign(key); + }; +} + +function git(args, { cwd }) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_CONFIG_NOSYSTEM: '1', + HOME: cwd, + }, + }); +} + +function gitAllowFail(args, { cwd }) { + try { + const stdout = git(args, { cwd }); + return { code: 0, output: stdout }; + } catch (err) { + return { + code: err.status ?? 1, + output: `${err.stdout ?? ''}${err.stderr ?? ''}`, + }; + } +} + +function tokenFromRemoteUrl(url) { + const afterScheme = url.split('://', 2)[1]; + const at = afterScheme.lastIndexOf('@'); + const userinfo = afterScheme.slice(0, at); + return userinfo.slice(userinfo.indexOf(':') + 1); +} + +function buildGitRemote(repoId, token) { + const host = storageBaseUrl; + const base = + host.includes('://') + ? host.replace(/\/$/, '') + : host.startsWith('127.0.0.1') || host.startsWith('localhost') + ? `http://${host}` + : `https://${host}`; + return `${base}/${repoId}.git`.replace('://', `://t:${token}@`); +} + +let encodeRefsClaim; + +async function loadGitStorage() { + const candidates = [ + path.resolve(__dirname, '../dist/index.js'), + path.resolve(__dirname, '../dist/index.mjs'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + const mod = await import(pathToFileURL(candidate).href); + encodeRefsClaim = mod.encodeRefsClaim; + return mod.GitStorage ?? mod.default; + } + } + throw new Error( + 'GitStorage dist build not found. Run "pnpm --filter @pierre/storage build" first.' + ); +} + +async function main() { + if (!existsSync(keyPath)) { + console.error(`FAIL: signing key not found at ${keyPath}`); + process.exit(1); + } + + const key = readFileSync(keyPath, 'utf8'); + const repoId = `sdk-refpol-ts-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + + const GitStorage = await loadGitStorage(); + patchGitStorage(GitStorage); + + const store = new GitStorage({ + name: issuer, + key, + apiBaseUrl, + storageBaseUrl, + }); + + console.log(`▶ TypeScript ref-policy live test (repo=${repoId})`); + + const repo = await store.createRepo({ id: repoId, defaultBranch: 'main' }); + console.log(' ✓ repo created'); + + const openUrl = await repo.getRemoteURL({ + permissions: ['git:read', 'git:write'], + ttl: 1800, + }); + const restrictedUrl = await repo.getRemoteURL({ + permissions: ['git:read', 'git:write'], + ttl: 600, + refs: [{ pattern: 'refs/heads/main', ops: ['no-push'] }], + }); + + const openToken = tokenFromRemoteUrl(openUrl); + const restrictedToken = tokenFromRemoteUrl(restrictedUrl); + const refsClaim = decodeJwtPayload(restrictedToken).refs; + const expected = [['refs/heads/main', ['no-push']]]; + if (JSON.stringify(refsClaim) !== JSON.stringify(expected)) { + console.error(`FAIL: unexpected refs claim: ${JSON.stringify(refsClaim)}`); + process.exit(1); + } + console.log(` ✓ JWT refs claim: ${JSON.stringify(refsClaim)}`); + + const work = mkdtempSync(path.join(os.tmpdir(), 'refpol-ts-')); + git(['init', '-b', 'main'], { cwd: work }); + git(['config', 'user.email', 'refpol@pierre.invalid'], { cwd: work }); + git(['config', 'user.name', 'RefPol Live'], { cwd: work }); + git(['config', 'commit.gpgsign', 'false'], { cwd: work }); + writeFileSync(path.join(work, 'README.md'), 'hello\n'); + git(['add', 'README.md'], { cwd: work }); + git(['commit', '-m', 'initial'], { cwd: work }); + + git(['remote', 'add', 'origin', buildGitRemote(repo.id, openToken)], { cwd: work }); + git(['push', '-u', 'origin', 'main'], { cwd: work }); + console.log(' ✓ seeded main via open token'); + + git(['checkout', '-b', 'feature/allowed'], { cwd: work }); + writeFileSync(path.join(work, 'README.md'), 'hello\nfeature\n'); + git(['add', 'README.md'], { cwd: work }); + git(['commit', '-m', 'feature commit'], { cwd: work }); + + git(['remote', 'set-url', 'origin', buildGitRemote(repo.id, restrictedToken)], { + cwd: work, + }); + git(['push', '-u', 'origin', 'feature/allowed'], { cwd: work }); + console.log(' ✓ feature branch push allowed'); + + git(['checkout', 'main'], { cwd: work }); + writeFileSync(path.join(work, 'README.md'), 'hello\nblocked\n'); + git(['add', 'README.md'], { cwd: work }); + git(['commit', '-m', 'main blocked attempt'], { cwd: work }); + const mainPush = gitAllowFail(['push', 'origin', 'main'], { cwd: work }); + if (mainPush.code === 0) { + console.error('FAIL: push to main should be denied by no-push policy'); + process.exit(1); + } + if (!mainPush.output.includes('denied by policy')) { + console.error( + `FAIL: expected 'denied by policy' in push output:\n${mainPush.output}` + ); + process.exit(1); + } + console.log(' ✓ main push denied by policy'); + + try { + await store.deleteRepo({ id: repoId }); + console.log(' ✓ repo deleted'); + } catch (err) { + console.log(` (cleanup warning: ${err.message})`); + } + + console.log('✅ TypeScript ref-policy live test passed'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 8503bbb..f5f8b69 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -44,7 +44,11 @@ Every request requires a JWT signed with your private key. Tokens are per-reposi "sub": "@pierre/storage", // Subject (the SDKs set this to the package name) "repo": "team/project", // Repository the token grants access to "scopes": ["git:read", "git:write"], - "ops": ["no-force-push"], // Optional policy operations (see below) + "ops": ["no-force-push"], // Optional repo-wide policy ops (legacy; see below) + "refs": [ // Optional per-ref policy rules (first match wins) + ["refs/heads/main", ["no-push"]], + ["refs/heads/feature/*", ["no-force-push"]] + ], "iat": 1723453189, "exp": 1723456789 } @@ -52,18 +56,55 @@ Every request requires a JWT signed with your private key. Tokens are per-reposi JWT header: `{ "alg": "ES256", "typ": "JWT" }` (RS256 and EdDSA also supported) -### Policy Operations (`ops`) +### Policy Operations -The optional `ops` claim narrows what a token may do at the protocol layer. The -SDKs expose the canonical constant for force-push prevention: +| Op string | SDK constant | Effect | +|------------------|------------------------------------------------------------------------------|-------------------------------------------------------| +| `no-force-push` | TS `OP_NO_FORCE_PUSH` / Py `OP_NO_FORCE_PUSH` / Go `storage.OpNoForcePush` | Rejects force pushes / non-fast-forward ref updates. | +| `no-push` | TS `OP_NO_PUSH` / Py `OP_NO_PUSH` / Go `storage.OpNoPush` | Rejects any push to matching refs. | -| Op string | SDK constant | Effect | -|------------------|---------------------------------------------------------------------------|-------------------------------------------------------| -| `no-force-push` | TS `OP_NO_FORCE_PUSH` / Py `OP_NO_FORCE_PUSH` / Go `storage.OpNoForcePush` | Rejects force pushes / non-fast-forward ref updates. | +#### Repo-wide `ops` (legacy) -Pass via the SDK `getRemoteURL` / `getEphemeralRemoteURL` / `getImportRemoteURL` -helpers (`{ ops: [OP_NO_FORCE_PUSH] }`) or include the `ops` array directly when -minting a JWT manually. +The optional top-level `ops` claim applies to every ref. On verify it is folded +into a trailing `*` rule when no explicit catch-all exists. + +Pass via `getRemoteURL` / `getEphemeralRemoteURL` / `getImportRemoteURL` +(`{ ops: [OP_NO_FORCE_PUSH] }`) or include `ops` directly when minting manually. + +#### Per-ref `refs` (preferred) + +The optional `refs` claim is an **ordered** array of `[pattern, [ops...]]` tuples. +Rules are evaluated in declaration order; the first pattern that matches the ref +wins. Patterns may be fully-qualified refs (`refs/heads/main`), prefix globs +(`refs/heads/feature/*`, `refs/tags/*`), or `*` for every ref. Short branch names +like `main` are normalized to `refs/heads/main` on verify. + +```typescript +await repo.getRemoteURL({ + refs: [ + { pattern: 'refs/heads/main', ops: [OP_NO_PUSH] }, + { pattern: '*', ops: [OP_NO_FORCE_PUSH] }, + ], +}); +``` + +```python +await repo.get_remote_url( + refs=[ + {"pattern": "refs/heads/main", "ops": [OP_NO_PUSH]}, + {"pattern": "*", "ops": [OP_NO_FORCE_PUSH]}, + ], +) +``` + +```go +repo.RemoteURL(ctx, storage.RemoteURLOptions{ + Refs: storage.RefPolicies{ + {Pattern: "refs/heads/main", Ops: storage.Ops{storage.OpNoPush}}, + {Pattern: "*", Ops: storage.Ops{storage.OpNoForcePush}}, + }, +}) +``` ### Permission Scopes @@ -799,8 +840,9 @@ safe_remote = await repo.get_remote_url( ``` The same `ops` array also works on `getEphemeralRemoteURL` and -`getImportRemoteURL`. When minting JWTs by hand, add `"ops": ["no-force-push"]` -to the payload before signing. +`getImportRemoteURL`. For per-ref rules, pass `refs` instead (see Policy +Operations above). When minting JWTs by hand, add `"ops"` or `"refs"` to the +payload before signing. ## PROCEDURE 8: Rollback a Branch @@ -897,5 +939,5 @@ git push origin feature-branch | Pagination | Cursor-based. Pass `next_cursor` as `cursor` param. Stop when `has_more: false`. | | Blob data encoding | Always base64. Max 4 MiB per chunk. Use multiple chunks for large files. | | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. | -| Policy ops (`ops`) | JWT-level guards. `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates. | +| Policy ops | JWT-level guards via `ops` (repo-wide) or `refs` (per-ref, first match wins). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates; `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. | | Merge endpoint | `POST /repos/merge`; strategies: `merge`, `ff_only`, `ff_prefer`; optional `squash` (not with `ff_only`). 409 on conflict. |