diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..465e985 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,72 @@ +package auth + +import ( + "context" + + gossh "golang.org/x/crypto/ssh" +) + +// Operation is the access a request needs. Transports map the git service: +// upload-pack/upload-archive → Read, receive-pack → Write. +type Operation int + +const ( + Read Operation = iota + Write +) + +// Credential is what the client presented. Exactly one concrete type per +// scheme; a transport constructs the variant it can produce, or Anonymous. +type Credential interface{ isCredential() } + +// Anonymous is "no credential presented" (git://, or HTTP/SSH with none). +type Anonymous struct{} + +// PublicKey is an SSH public key. Uses x/crypto/ssh's type (gliderlabs/ssh +// keys satisfy it) so this package stays free of the SSH server library. +type PublicKey struct{ Key gossh.PublicKey } + +// BasicAuth is an HTTP Basic credential. Unvalidated — the Authorizer owns the +// user store. +type BasicAuth struct{ Username, Password string } + +func (Anonymous) isCredential() {} +func (PublicKey) isCredential() {} +func (BasicAuth) isCredential() {} + +// Request is a transport-neutral authorization request. +type Request struct { + Repo string + Operation Operation + Cred Credential + Transport string // "git", "ssh", "http" — for policy/logging +} + +// Decision is the outcome. Unauthenticated is the seam that lets HTTP issue a +// 401 challenge; SSH and git:// treat it as Deny. +type Decision int + +const ( + Deny Decision = iota + Allow + Unauthenticated +) + +// Authorizer decides whether a request may proceed. This is the seam a real +// authn/authz layer plugs into later. +type Authorizer interface { + Authorize(ctx context.Context, req Request) Decision +} + +// AllowAnonymous is the permissive default: read for everyone, write only when +// AllowWrite is set. "Dangerously allow everything the server is configured to +// allow" — never more open than the -allow-push gate. It ignores the credential +// entirely and never returns Unauthenticated. +type AllowAnonymous struct{ AllowWrite bool } + +func (a AllowAnonymous) Authorize(_ context.Context, req Request) Decision { + if req.Operation == Write && !a.AllowWrite { + return Deny + } + return Allow +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..cc893db --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,30 @@ +package auth + +import ( + "context" + "testing" +) + +func TestAllowAnonymousAuthorize(t *testing.T) { + tests := []struct { + name string + allowWrite bool + op Operation + want Decision + }{ + {name: "no-write/read", allowWrite: false, op: Read, want: Allow}, + {name: "no-write/write", allowWrite: false, op: Write, want: Deny}, + {name: "allow-write/read", allowWrite: true, op: Read, want: Allow}, + {name: "allow-write/write", allowWrite: true, op: Write, want: Allow}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := AllowAnonymous{AllowWrite: tt.allowWrite} + got := a.Authorize(context.Background(), Request{Operation: tt.op}) + if got != tt.want { + t.Errorf("Authorize() = %v, want %v", got, tt.want) + } + }) + } +}