From adfeda235a04c1a31e8ddb95b40f866959c76ef6 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Sun, 5 Apr 2026 10:37:17 +0100 Subject: More robust error handling and authorization --- cmd/mirumd/api_auth.go | 47 +- cmd/mirumd/api_cli.go | 427 +++++++++++++ cmd/mirumd/cert.go | 67 ++ cmd/mirumd/cert_test.go | 142 +++++ cmd/mirumd/config.go | 6 +- cmd/mirumd/main.go | 588 +++--------------- cmd/mirumd/server.go | 8 +- cmd/mirumd/server_admin.go | 112 ++-- cmd/mirumd/server_grpc.go | 20 +- cmd/mirumd/server_web.go | 144 +++-- cmd/mirumd/static.go | 5 +- cmd/mirumd/web/api/client.ts | 29 +- cmd/mirumd/web/components/pages/dashboard.tsx | 16 +- cmd/mirumd/web/components/pages/error.tsx | 47 ++ cmd/mirumd/web/components/pages/login.tsx | 10 +- cmd/mirumd/web/entries/error.tsx | 8 + cmd/mirumd/web/lib/errors.ts | 84 +++ cmd/mirumd/web/shell.html | 5 + internal/config/config.go | 54 ++ internal/database/actor.go | 117 ++++ internal/database/database.go | 40 +- internal/database/organization.go | 20 +- internal/database/user.go | 83 +-- internal/database/worker.go | 10 +- internal/protocol/backoff.go | 4 +- internal/protocol/handshake.go | 4 +- proto/admin.proto | 47 ++ 27 files changed, 1416 insertions(+), 728 deletions(-) create mode 100644 cmd/mirumd/api_cli.go create mode 100644 cmd/mirumd/cert.go create mode 100644 cmd/mirumd/cert_test.go create mode 100644 cmd/mirumd/web/components/pages/error.tsx create mode 100644 cmd/mirumd/web/entries/error.tsx create mode 100644 cmd/mirumd/web/lib/errors.ts create mode 100644 internal/config/config.go create mode 100644 internal/database/actor.go diff --git a/cmd/mirumd/api_auth.go b/cmd/mirumd/api_auth.go index d278d3c..38abde4 100644 --- a/cmd/mirumd/api_auth.go +++ b/cmd/mirumd/api_auth.go @@ -49,11 +49,11 @@ func (a *ApiAuthInterceptor) authorize(ctx context.Context, procedure string, re return nil } - caller := CallerFromContext(ctx) - if caller == nil { - return connect.NewError(connect.CodeUnauthenticated, nil) + actor := ActorFromContext(ctx) + if actor.Kind() == database.KindAnon { + return errUnauthenticated } - if caller.Superuser { + if actor.IsSuperuser() { return nil } @@ -66,7 +66,7 @@ func (a *ApiAuthInterceptor) authorize(ctx context.Context, procedure string, re return errDenied } - if isSelf(*caller, req) { + if isSelf(actor, req) { return nil } return errDenied @@ -80,18 +80,18 @@ func (a *ApiAuthInterceptor) authorize(ctx context.Context, procedure string, re case pbconnect.AdminOrgCreateProcedure: return nil case pbconnect.AdminOrgUpdateProcedure: - return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_ORG_WRITE) + return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_ORG_WRITE) case pbconnect.AdminOrgDeleteProcedure: - return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_ORG_DELETE) + return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_ORG_DELETE) // OrgMember — org-scoped case pbconnect.AdminOrgMemberGetProcedure, pbconnect.AdminOrgMemberListProcedure: - return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_ORG_MEMBER_READ) + return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_ORG_MEMBER_READ) case pbconnect.AdminOrgMemberAddProcedure, pbconnect.AdminOrgMemberUpdateProcedure, pbconnect.AdminOrgMemberRemoveProcedure: - return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_ORG_MEMBER_WRITE) + return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_ORG_MEMBER_WRITE) // Worker — any authenticated can read case pbconnect.AdminWorkerGetProcedure, @@ -103,26 +103,29 @@ func (a *ApiAuthInterceptor) authorize(ctx context.Context, procedure string, re if orgRefFromRequest(req) == nil { return errDenied } - return a.checkOrgPerm(ctx, *caller, req, pb.Perm_PERM_WORKER_WRITE) + return a.checkOrgPerm(ctx, actor, req, pb.Perm_PERM_WORKER_WRITE) // Worker delete — lookup worker's org, then check case pbconnect.AdminWorkerDeleteProcedure: - return a.checkWorkerDelete(ctx, *caller, req) + return a.checkWorkerDelete(ctx, actor, req) default: return errDenied } } -var errDenied = connect.NewError(connect.CodePermissionDenied, nil) +var ( + errDenied = newAPIError(connect.CodePermissionDenied, pb.ErrorReason_ERROR_REASON_PERMISSION_DENIED, nil) + errUnauthenticated = newAPIError(connect.CodeUnauthenticated, pb.ErrorReason_ERROR_REASON_UNAUTHENTICATED, nil) +) -// checkOrgPerm extracts OrgRef from request and checks caller's role permission. -func (a *ApiAuthInterceptor) checkOrgPerm(ctx context.Context, caller callerInfo, req connect.AnyRequest, perm pb.Perm) error { +// checkOrgPerm extracts OrgRef from request and checks the actor's role permission. +func (a *ApiAuthInterceptor) checkOrgPerm(ctx context.Context, actor database.Actor, req connect.AnyRequest, perm pb.Perm) error { ref := orgRefFromRequest(req) if ref == nil { return errDenied } - member, err := a.srv.db.GetOrgMember(ctx, caller.UserID, orgRef(ref), database.UserByID(caller.UserID)) + member, err := a.srv.db.GetOrgMember(ctx, actor, orgRef(ref), database.UserByID(actor.UserID())) if err != nil { return errDenied } @@ -133,19 +136,19 @@ func (a *ApiAuthInterceptor) checkOrgPerm(ctx context.Context, caller callerInfo } // checkWorkerDelete looks up the worker's org and checks permission. -func (a *ApiAuthInterceptor) checkWorkerDelete(ctx context.Context, caller callerInfo, req connect.AnyRequest) error { +func (a *ApiAuthInterceptor) checkWorkerDelete(ctx context.Context, actor database.Actor, req connect.AnyRequest) error { m, ok := req.Any().(*pb.WorkerDeleteRequest) if !ok { return errDenied } - w, err := a.srv.db.GetWorker(ctx, caller.UserID, uuid.UUID(m.Id)) + w, err := a.srv.db.GetWorker(ctx, actor, uuid.UUID(m.Id)) if err != nil { return errDenied } if w.OrgID == nil { return errDenied // global worker — superuser only } - member, err := a.srv.db.GetOrgMember(ctx, caller.UserID, database.OrgByID(*w.OrgID), database.UserByID(caller.UserID)) + member, err := a.srv.db.GetOrgMember(ctx, actor, database.OrgByID(*w.OrgID), database.UserByID(actor.UserID())) if err != nil { return errDenied } @@ -155,8 +158,8 @@ func (a *ApiAuthInterceptor) checkWorkerDelete(ctx context.Context, caller calle return nil } -// isSelf checks if the request targets the caller's own user. -func isSelf(caller callerInfo, req connect.AnyRequest) bool { +// isSelf checks if the request targets the actor's own user. +func isSelf(actor database.Actor, req connect.AnyRequest) bool { var ref *pb.UserRef switch m := req.Any().(type) { case *pb.UserGetRequest: @@ -171,9 +174,9 @@ func isSelf(caller callerInfo, req connect.AnyRequest) bool { } switch v := ref.GetRef().(type) { case *pb.UserRef_Id: - return uuid.UUID(v.Id) == caller.UserID + return uuid.UUID(v.Id) == actor.UserID() case *pb.UserRef_Email: - return v.Email == caller.Email + return v.Email == actor.Email() default: return false } diff --git a/cmd/mirumd/api_cli.go b/cmd/mirumd/api_cli.go new file mode 100644 index 0000000..6769798 --- /dev/null +++ b/cmd/mirumd/api_cli.go @@ -0,0 +1,427 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +// Admin CLI is generated from admin.proto at startup via protoreflect. +// RPC name is camelCase-split into a cobra path: UserCreate -> "user create", +// OrgMemberAdd -> "org member add". Flags come from request fields, dispatch +// goes through reflect on pbconnect.AdminClient. + +import ( + "context" + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "fmt" + "os" + "reflect" + "strings" + "unicode" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/timestamppb" + + "dimidiumlabs/mirum/internal/protocol/pb" + "dimidiumlabs/mirum/internal/protocol/pb/pbconnect" +) + +// mkClient is called per-invocation so persistent flags (e.g. --socket) are +// already parsed by the time it runs. +func buildAdminCLI(root *cobra.Command, mkClient func() pbconnect.AdminClient) { + methods := pb.File_admin_proto.Services().ByName("Admin").Methods() + for i := 0; i < methods.Len(); i++ { + md := methods.Get(i) + path := splitCamel(string(md.Name())) + parent := ensureGroups(root, path[:len(path)-1]) + parent.AddCommand(buildMethodCmd(md, path[len(path)-1], mkClient)) + } +} + +// "OrgMemberAdd" -> ["org","member","add"]. +func splitCamel(s string) []string { + var parts []string + start := 0 + for i := 1; i < len(s); i++ { + if unicode.IsUpper(rune(s[i])) { + parts = append(parts, strings.ToLower(s[start:i])) + start = i + } + } + return append(parts, strings.ToLower(s[start:])) +} + +func ensureGroups(root *cobra.Command, path []string) *cobra.Command { + parent := root + for _, name := range path { + var next *cobra.Command + for _, c := range parent.Commands() { + if c.Name() == name { + next = c + break + } + } + if next == nil { + next = &cobra.Command{Use: name, Short: "Manage " + name} + parent.AddCommand(next) + } + parent = next + } + return parent +} + +// fieldSetter writes one flag value into the request message. +type fieldSetter func(*pflag.FlagSet, protoreflect.Message) error + +func buildMethodCmd(md protoreflect.MethodDescriptor, leaf string, mkClient func() pbconnect.AdminClient) *cobra.Command { + reqDesc := md.Input() + rpcName := string(md.Name()) + cmd := &cobra.Command{ + Use: leaf, + Short: rpcName, + } + setters := registerRequestFlags(cmd, reqDesc) + cmd.Run = func(c *cobra.Command, _ []string) { + req, err := buildRequest(reqDesc, c.Flags(), setters) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + resp, err := dispatchAdmin(mkClient(), rpcName, req) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + printResponse(resp) + } + return cmd +} + +func registerRequestFlags(cmd *cobra.Command, desc protoreflect.MessageDescriptor) []fieldSetter { + var setters []fieldSetter + fields := desc.Fields() + for i := 0; i < fields.Len(); i++ { + if s := registerField(cmd, fields.Get(i)); s != nil { + setters = append(setters, s) + } + } + return setters +} + +func registerField(cmd *cobra.Command, fd protoreflect.FieldDescriptor) fieldSetter { + flagName := strings.ReplaceAll(string(fd.Name()), "_", "-") + // Required iff non-optional in the schema; bools default to false, so + // marking them required makes no sense. + required := !fd.HasOptionalKeyword() && fd.Kind() != protoreflect.BoolKind + flags := cmd.Flags() + + markRequired := func() { + if required { + _ = cmd.MarkFlagRequired(flagName) + } + } + + switch fd.Kind() { + case protoreflect.StringKind: + flags.String(flagName, "", string(fd.Name())) + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + m.Set(fd, protoreflect.ValueOfString(v)) + return nil + } + + case protoreflect.BoolKind: + flags.Bool(flagName, false, string(fd.Name())) + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + // Preserve "unset" vs "false" for optional bools. + if fd.HasOptionalKeyword() && !fs.Changed(flagName) { + return nil + } + v, _ := fs.GetBool(flagName) + m.Set(fd, protoreflect.ValueOfBool(v)) + return nil + } + + case protoreflect.BytesKind: + flags.String(flagName, "", string(fd.Name())) + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + b, err := parseBytesFlag(string(fd.Name()), v) + if err != nil { + return fmt.Errorf("--%s: %w", flagName, err) + } + m.Set(fd, protoreflect.ValueOfBytes(b)) + return nil + } + + case protoreflect.EnumKind: + flags.String(flagName, "", string(fd.Name())) + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + n, err := parseEnumFlag(fd.Enum(), v) + if err != nil { + return fmt.Errorf("--%s: %w", flagName, err) + } + m.Set(fd, protoreflect.ValueOfEnum(n)) + return nil + } + + case protoreflect.MessageKind: + return registerMessageField(cmd, fd, flagName, required) + } + + if required { + panic(fmt.Sprintf("admincli: unhandled required field %s (kind=%s)", fd.FullName(), fd.Kind())) + } + return nil +} + +// Only UserRef/OrgRef are flattened (to the string arm of their oneof); +// PageRequest is skipped; anything else required panics at startup so +// schema changes can't silently send malformed requests. +func registerMessageField(cmd *cobra.Command, fd protoreflect.FieldDescriptor, flagName string, required bool) fieldSetter { + flags := cmd.Flags() + markRequired := func() { + if required { + _ = cmd.MarkFlagRequired(flagName) + } + } + + switch fd.Message().FullName() { + case "mirum.UserRef": + flags.String(flagName, "", "user email") + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + ref := &pb.UserRef{Ref: &pb.UserRef_Email{Email: v}} + m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect())) + return nil + } + + case "mirum.OrgRef": + flags.String(flagName, "", "org slug") + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + ref := &pb.OrgRef{Ref: &pb.OrgRef_Slug{Slug: v}} + m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect())) + return nil + } + + case "mirum.PageRequest": + return nil + } + + if required { + panic(fmt.Sprintf("admincli: unhandled required message field %s (type=%s)", fd.FullName(), fd.Message().FullName())) + } + return nil +} + +// Admin schema uses bytes only for UUIDs (id / *_id) and ed25519 PKIX keys. +func parseBytesFlag(fieldName, v string) ([]byte, error) { + switch { + case fieldName == "id" || strings.HasSuffix(fieldName, "_id"): + u, err := uuid.Parse(v) + if err != nil { + return nil, fmt.Errorf("invalid uuid: %w", err) + } + return u[:], nil + case strings.Contains(fieldName, "key"): + der, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return nil, fmt.Errorf("invalid base64: %w", err) + } + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("invalid public key: %w", err) + } + ed, ok := pub.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("not an ed25519 key") + } + return ed, nil + } + return nil, fmt.Errorf("unsupported bytes field %q", fieldName) +} + +// Accepts both short ("admin") and full ("ROLE_ADMIN") forms. +func parseEnumFlag(ed protoreflect.EnumDescriptor, v string) (protoreflect.EnumNumber, error) { + want := strings.ToUpper(v) + values := ed.Values() + if ev := values.ByName(protoreflect.Name(want)); ev != nil { + return ev.Number(), nil + } + prefix := strings.ToUpper(string(ed.Name())) + "_" + if ev := values.ByName(protoreflect.Name(prefix + want)); ev != nil { + return ev.Number(), nil + } + return 0, fmt.Errorf("unknown %s value %q", ed.Name(), v) +} + +func buildRequest(desc protoreflect.MessageDescriptor, flags *pflag.FlagSet, setters []fieldSetter) (proto.Message, error) { + mt, err := protoregistry.GlobalTypes.FindMessageByName(desc.FullName()) + if err != nil { + return nil, fmt.Errorf("find message %s: %w", desc.FullName(), err) + } + m := mt.New() + for _, set := range setters { + if err := set(flags, m); err != nil { + return nil, err + } + } + return m.Interface(), nil +} + +// reflect.New on *connect.Request[T] is equivalent to connect.NewRequest(req): +// Msg is the only public field, the rest are initialised lazily at send time. +func dispatchAdmin(client pbconnect.AdminClient, name string, req proto.Message) (proto.Message, error) { + cv := reflect.ValueOf(client) + method := cv.MethodByName(name) + if !method.IsValid() { + return nil, fmt.Errorf("unknown admin method %q", name) + } + // method signature: + // func(context.Context, *connect.Request[T]) (*connect.Response[U], error) + reqPtrType := method.Type().In(1) // *connect.Request[T] + reqWrap := reflect.New(reqPtrType.Elem()) + reqWrap.Elem().FieldByName("Msg").Set(reflect.ValueOf(req)) + + out := method.Call([]reflect.Value{ + reflect.ValueOf(context.Background()), + reqWrap, + }) + if errV := out[1]; !errV.IsNil() { + return nil, errV.Interface().(error) + } + return out[0].Elem().FieldByName("Msg").Interface().(proto.Message), nil +} + +// Shape-driven printer: empty → "ok", single bytes id → UUID, single message +// → TSV of scalars, repeated → one TSV line per element, anything else → TSV +// of top-level scalars. PageResponse metadata is ignored. +func printResponse(resp proto.Message) { + m := resp.ProtoReflect() + fields := m.Descriptor().Fields() + + var meaningful []protoreflect.FieldDescriptor + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + if f.Kind() == protoreflect.MessageKind && f.Message().FullName() == "mirum.PageResponse" { + continue + } + meaningful = append(meaningful, f) + } + + if len(meaningful) == 0 { + fmt.Println("ok") + return + } + if len(meaningful) == 1 { + f := meaningful[0] + v := m.Get(f) + switch { + case f.IsList(): + list := v.List() + for j := 0; j < list.Len(); j++ { + item := list.Get(j) + if f.Kind() == protoreflect.MessageKind { + fmt.Println(formatMessageTSV(item.Message())) + } else { + fmt.Println(formatScalar(f, item)) + } + } + case f.Kind() == protoreflect.MessageKind: + fmt.Println(formatMessageTSV(v.Message())) + default: + fmt.Println(formatScalar(f, v)) + } + return + } + fmt.Println(formatMessageTSV(m)) +} + +func formatMessageTSV(m protoreflect.Message) string { + var parts []string + fields := m.Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + if f.IsList() || f.IsMap() { + continue + } + if f.HasOptionalKeyword() && !m.Has(f) { + continue + } + if f.Kind() == protoreflect.MessageKind { + if f.Message().FullName() == "google.protobuf.Timestamp" { + ts := m.Get(f).Message().Interface().(*timestamppb.Timestamp) + parts = append(parts, ts.AsTime().Format("2006-01-02")) + continue + } + nested := m.Get(f).Message() + if nested.IsValid() { + parts = append(parts, formatMessageTSV(nested)) + } + continue + } + parts = append(parts, formatScalar(f, m.Get(f))) + } + return strings.Join(parts, "\t") +} + +func formatScalar(fd protoreflect.FieldDescriptor, v protoreflect.Value) string { + switch fd.Kind() { + case protoreflect.StringKind: + return v.String() + case protoreflect.BoolKind: + if v.Bool() { + return "true" + } + return "false" + case protoreflect.BytesKind: + return formatBytes(v.Bytes()) + case protoreflect.EnumKind: + ev := fd.Enum().Values().ByNumber(v.Enum()) + if ev == nil { + return fmt.Sprintf("%d", v.Enum()) + } + name := string(ev.Name()) + if idx := strings.IndexByte(name, '_'); idx >= 0 { + name = name[idx+1:] + } + return strings.ToLower(name) + } + return v.String() +} + +func formatBytes(b []byte) string { + if len(b) == 16 { + if u, err := uuid.FromBytes(b); err == nil { + return u.String() + } + } + return base64.StdEncoding.EncodeToString(b) +} diff --git a/cmd/mirumd/cert.go b/cmd/mirumd/cert.go new file mode 100644 index 0000000..67128d5 --- /dev/null +++ b/cmd/mirumd/cert.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/tls" + "log/slog" + "os" + "sync" + "time" +) + +// certReloader serves a TLS cert/key pair and reloads it when either file +// changes on disk (e.g. after a letsencrypt renewal). +type certReloader struct { + certFile string + keyFile string + + mu sync.Mutex + cert *tls.Certificate + certMod time.Time + keyMod time.Time +} + +func newCertReloader(certFile, keyFile string) *certReloader { + r := &certReloader{certFile: certFile, keyFile: keyFile} + if _, err := r.GetCertificate(nil); err != nil { + slog.Warn("initial cert load failed", "cert", certFile, "err", err) + } + return r +} + +// GetCertificate plugs into tls.Config.GetCertificate. On a transient +// reload error it returns the last good pair so a mid-renewal race +// (cert swapped but key still being written) doesn't break handshakes. +func (r *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { + cs, cerr := os.Stat(r.certFile) + ks, kerr := os.Stat(r.keyFile) + + r.mu.Lock() + defer r.mu.Unlock() + + if cerr == nil && kerr == nil && r.cert != nil && + cs.ModTime().Equal(r.certMod) && ks.ModTime().Equal(r.keyMod) { + return r.cert, nil + } + + cert, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) + if err != nil { + if r.cert != nil { + slog.Warn("cert reload failed, serving cached", "cert", r.certFile, "err", err) + return r.cert, nil + } + return nil, err + } + + r.cert = &cert + if cerr == nil { + r.certMod = cs.ModTime() + } + if kerr == nil { + r.keyMod = ks.ModTime() + } + slog.Info("cert loaded", "cert", r.certFile) + return r.cert, nil +} diff --git a/cmd/mirumd/cert_test.go b/cmd/mirumd/cert_test.go new file mode 100644 index 0000000..794b2f6 --- /dev/null +++ b/cmd/mirumd/cert_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +func writeTestPair(t *testing.T, certPath, keyPath string) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Fatal(err) + } + + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: serial, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + }, &x509.Certificate{SerialNumber: serial}, pub, priv) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(certPath, + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + 0o644); err != nil { + t.Fatal(err) + } + + keyDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, + pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), + 0o600); err != nil { + t.Fatal(err) + } +} + +func TestCertReloader_Cached(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + writeTestPair(t, certPath, keyPath) + + r := newCertReloader(certPath, keyPath) + + first, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + second, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("expected same pointer on cache hit") + } +} + +func TestCertReloader_ReloadsOnMtimeChange(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + writeTestPair(t, certPath, keyPath) + + r := newCertReloader(certPath, keyPath) + first, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + + writeTestPair(t, certPath, keyPath) + future := time.Now().Add(time.Second) + if err := os.Chtimes(certPath, future, future); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(keyPath, future, future); err != nil { + t.Fatal(err) + } + + second, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatal("expected new pointer after mtime change") + } +} + +func TestCertReloader_FallbackOnReloadError(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + writeTestPair(t, certPath, keyPath) + + r := newCertReloader(certPath, keyPath) + good, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(certPath, []byte("garbage"), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(time.Second) + if err := os.Chtimes(certPath, future, future); err != nil { + t.Fatal(err) + } + + fallback, err := r.GetCertificate(nil) + if err != nil { + t.Fatalf("expected last-good fallback, got error: %v", err) + } + if fallback != good { + t.Fatal("expected cached cert on corrupted file") + } +} + +func TestCertReloader_ErrorOnFirstLoad(t *testing.T) { + r := newCertReloader("/nonexistent/cert.pem", "/nonexistent/key.pem") + if _, err := r.GetCertificate(nil); err == nil { + t.Fatal("expected error on missing files") + } +} diff --git a/cmd/mirumd/config.go b/cmd/mirumd/config.go index fb7a2e0..854e7ec 100644 --- a/cmd/mirumd/config.go +++ b/cmd/mirumd/config.go @@ -15,7 +15,7 @@ type tlsConfig struct { Key string `yaml:"key"` } -type config struct { +type appConfig struct { WebAddr string `yaml:"web_addr"` GrpcAddr string `yaml:"grpc_addr"` AdminSocket string `yaml:"admin_socket"` @@ -31,8 +31,8 @@ type config struct { WebhookSecret string `yaml:"webhook_secret"` } -func getConfig(filename string) (*config, error) { - cfg := &config{ +func getConfig(filename string) (*appConfig, error) { + cfg := &appConfig{ GrpcAddr: ":2026", WebAddr: ":3000", AdminSocket: "/run/mirumd/admin.sock", diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go index d60c4eb..9898929 100644 --- a/cmd/mirumd/main.go +++ b/cmd/mirumd/main.go @@ -5,19 +5,14 @@ package main import ( "context" - "crypto/ed25519" - "crypto/x509" - "encoding/base64" + "errors" "fmt" "log/slog" "net" "net/http" "os" - "time" - - "connectrpc.com/connect" - "github.com/google/uuid" + "dimidiumlabs/mirum/internal/config" "dimidiumlabs/mirum/internal/database" "dimidiumlabs/mirum/internal/forges" "dimidiumlabs/mirum/internal/protocol/pb" @@ -29,9 +24,9 @@ import ( ) func hardenServer(s *http.Server) *http.Server { - s.IdleTimeout = 120 * time.Second - s.MaxHeaderBytes = 1 << 16 - s.ReadHeaderTimeout = 10 * time.Second + s.IdleTimeout = config.HTTPIdleTimeout + s.MaxHeaderBytes = config.HTTPMaxHeaderBytes + s.ReadHeaderTimeout = config.HTTPReadHeaderTimeout return s } @@ -40,234 +35,41 @@ func main() { root := &cobra.Command{Use: "mirumd", Short: "Mirum CI server"} root.PersistentFlags().StringVar(&socketPath, "socket", "", "admin socket path (default from config or /run/mirumd/admin.sock)") + root.AddGroup(&cobra.Group{ID: "main", Title: "Commands:"}) daemonCmd := &cobra.Command{ - Use: "daemon", - Short: "Start the server", - Run: func(cmd *cobra.Command, args []string) { + Use: "daemon", + Short: "Start the server", + GroupID: "main", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { configFile, _ := cmd.Flags().GetString("config") - daemon(configFile, socketPath) + return daemon(configFile, socketPath) }, } - root.AddCommand(daemonCmd) daemonCmd.Flags().String("config", "", "path to config file") _ = daemonCmd.MarkFlagRequired("config") + root.AddCommand(daemonCmd) - userCmd := &cobra.Command{Use: "user", Short: "Manage users"} - root.AddCommand(userCmd) - - userCreateCmd := &cobra.Command{ - Use: "create", - Short: "Create a user", - Run: func(cmd *cobra.Command, args []string) { - email, _ := cmd.Flags().GetString("email") - password, _ := cmd.Flags().GetString("password") - userCreate(socketPath, email, password) - }, - } - userCmd.AddCommand(userCreateCmd) - userCreateCmd.Flags().String("email", "", "user email") - userCreateCmd.Flags().String("password", "", "user password") - _ = userCreateCmd.MarkFlagRequired("email") - _ = userCreateCmd.MarkFlagRequired("password") - - setPasswordCmd := &cobra.Command{ - Use: "set-password", - Short: "Set user password", - Run: func(cmd *cobra.Command, args []string) { - email, _ := cmd.Flags().GetString("email") - password, _ := cmd.Flags().GetString("password") - userSetPassword(socketPath, email, password) - }, - } - userCmd.AddCommand(setPasswordCmd) - setPasswordCmd.Flags().String("email", "", "user email") - setPasswordCmd.Flags().String("password", "", "new password") - _ = setPasswordCmd.MarkFlagRequired("email") - _ = setPasswordCmd.MarkFlagRequired("password") - - deleteUserCmd := &cobra.Command{ - Use: "delete", - Short: "Delete a user", - Run: func(cmd *cobra.Command, args []string) { - email, _ := cmd.Flags().GetString("email") - userDelete(socketPath, email) - }, - } - userCmd.AddCommand(deleteUserCmd) - deleteUserCmd.Flags().String("email", "", "user email") - _ = deleteUserCmd.MarkFlagRequired("email") - - workerCmd := &cobra.Command{Use: "worker", Short: "Manage workers"} - root.AddCommand(workerCmd) - - workerAddCmd := &cobra.Command{ - Use: "add", - Short: "Register a worker", - Run: func(cmd *cobra.Command, args []string) { - pubkey, _ := cmd.Flags().GetString("pubkey") - workerAdd(socketPath, pubkey) - }, - } - workerCmd.AddCommand(workerAddCmd) - workerAddCmd.Flags().String("pubkey", "", "base64-encoded ed25519 public key") - _ = workerAddCmd.MarkFlagRequired("pubkey") - - workerRevokeCmd := &cobra.Command{ - Use: "revoke", - Short: "Revoke a worker", - Run: func(cmd *cobra.Command, args []string) { - id, _ := cmd.Flags().GetString("id") - workerRevoke(socketPath, id) - }, - } - workerCmd.AddCommand(workerRevokeCmd) - workerRevokeCmd.Flags().String("id", "", "worker ID") - _ = workerRevokeCmd.MarkFlagRequired("id") - - workerListCmd := &cobra.Command{ - Use: "list", - Short: "List active workers", - Run: func(cmd *cobra.Command, args []string) { - workerList(socketPath) - }, - } - workerCmd.AddCommand(workerListCmd) - - orgCmd := &cobra.Command{Use: "org", Short: "Manage organizations"} - root.AddCommand(orgCmd) - - orgCreateCmd := &cobra.Command{ - Use: "create", - Short: "Create an organization", - Run: func(cmd *cobra.Command, args []string) { - name, _ := cmd.Flags().GetString("name") - slug, _ := cmd.Flags().GetString("slug") - public, _ := cmd.Flags().GetBool("public") - owner, _ := cmd.Flags().GetString("owner") - orgCreate(socketPath, name, slug, public, owner) - }, - } - orgCmd.AddCommand(orgCreateCmd) - orgCreateCmd.Flags().String("name", "", "display name") - orgCreateCmd.Flags().String("slug", "", "URL slug") - orgCreateCmd.Flags().Bool("public", false, "public visibility") - orgCreateCmd.Flags().String("owner", "", "owner email") - _ = orgCreateCmd.MarkFlagRequired("name") - _ = orgCreateCmd.MarkFlagRequired("slug") - _ = orgCreateCmd.MarkFlagRequired("owner") - - orgDeleteCmd := &cobra.Command{ - Use: "delete", - Short: "Delete an organization", - Run: func(cmd *cobra.Command, args []string) { - slug, _ := cmd.Flags().GetString("slug") - orgDelete(socketPath, slug) - }, - } - orgCmd.AddCommand(orgDeleteCmd) - orgDeleteCmd.Flags().String("slug", "", "org slug") - _ = orgDeleteCmd.MarkFlagRequired("slug") - - orgRenameCmd := &cobra.Command{ - Use: "rename", - Short: "Rename an organization", - Run: func(cmd *cobra.Command, args []string) { - slug, _ := cmd.Flags().GetString("slug") - newName, _ := cmd.Flags().GetString("name") - newSlug, _ := cmd.Flags().GetString("new-slug") - orgRename(socketPath, slug, newName, newSlug) - }, - } - orgCmd.AddCommand(orgRenameCmd) - orgRenameCmd.Flags().String("slug", "", "current slug") - orgRenameCmd.Flags().String("name", "", "new display name") - orgRenameCmd.Flags().String("new-slug", "", "new slug") - _ = orgRenameCmd.MarkFlagRequired("slug") - _ = orgRenameCmd.MarkFlagRequired("name") - _ = orgRenameCmd.MarkFlagRequired("new-slug") - - orgListCmd := &cobra.Command{ - Use: "list", - Short: "List organizations", - Run: func(cmd *cobra.Command, args []string) { - orgList(socketPath) - }, - } - orgCmd.AddCommand(orgListCmd) - - orgMemberAddCmd := &cobra.Command{ - Use: "add-member", - Short: "Add a member to an organization", - Run: func(cmd *cobra.Command, args []string) { - org, _ := cmd.Flags().GetString("org") - email, _ := cmd.Flags().GetString("email") - role, _ := cmd.Flags().GetString("role") - orgMemberAdd(socketPath, org, email, role) - }, - } - orgCmd.AddCommand(orgMemberAddCmd) - orgMemberAddCmd.Flags().String("org", "", "org slug") - orgMemberAddCmd.Flags().String("email", "", "user email") - orgMemberAddCmd.Flags().String("role", "member", "role (owner, admin, member)") - _ = orgMemberAddCmd.MarkFlagRequired("org") - _ = orgMemberAddCmd.MarkFlagRequired("email") - - orgMemberRemoveCmd := &cobra.Command{ - Use: "remove-member", - Short: "Remove a member from an organization", - Run: func(cmd *cobra.Command, args []string) { - org, _ := cmd.Flags().GetString("org") - email, _ := cmd.Flags().GetString("email") - orgMemberRemove(socketPath, org, email) - }, - } - orgCmd.AddCommand(orgMemberRemoveCmd) - orgMemberRemoveCmd.Flags().String("org", "", "org slug") - orgMemberRemoveCmd.Flags().String("email", "", "user email") - _ = orgMemberRemoveCmd.MarkFlagRequired("org") - _ = orgMemberRemoveCmd.MarkFlagRequired("email") - - orgSetRoleCmd := &cobra.Command{ - Use: "set-role", - Short: "Change a member's role", - Run: func(cmd *cobra.Command, args []string) { - org, _ := cmd.Flags().GetString("org") - email, _ := cmd.Flags().GetString("email") - role, _ := cmd.Flags().GetString("role") - orgMemberSetRole(socketPath, org, email, role) - }, - } - orgCmd.AddCommand(orgSetRoleCmd) - orgSetRoleCmd.Flags().String("org", "", "org slug") - orgSetRoleCmd.Flags().String("email", "", "user email") - orgSetRoleCmd.Flags().String("role", "", "new role (owner, admin, member)") - _ = orgSetRoleCmd.MarkFlagRequired("org") - _ = orgSetRoleCmd.MarkFlagRequired("email") - _ = orgSetRoleCmd.MarkFlagRequired("role") - - orgMemberListCmd := &cobra.Command{ - Use: "list-members", - Short: "List members of an organization", - Run: func(cmd *cobra.Command, args []string) { - org, _ := cmd.Flags().GetString("org") - orgMemberList(socketPath, org) - }, + // Admin subcommands are generated from admin.proto via reflection. + buildAdminCLI(root, func() pbconnect.AdminClient { return adminClient(socketPath) }) + for _, c := range root.Commands() { + if c.GroupID == "" { + c.GroupID = "main" + } } - orgCmd.AddCommand(orgMemberListCmd) - orgMemberListCmd.Flags().String("org", "", "org slug") - _ = orgMemberListCmd.MarkFlagRequired("org") if err := root.Execute(); err != nil { os.Exit(1) } } -func daemon(configFile, socketFlag string) { +func daemon(configFile, socketFlag string) error { cfg, err := getConfig(configFile) if err != nil { slog.Error("config parsing failed", "err", err) - os.Exit(1) + return err } if socketFlag != "" { @@ -277,28 +79,29 @@ func daemon(configFile, socketFlag string) { slog.Info("config loaded", "configfile", configFile) sup := supervisor.Detect() - ctx := sup.WaitForStop(context.Background()) + ctx, cancel := context.WithCancel(sup.WaitForStop(context.Background())) + defer cancel() db, err := database.Open(ctx, cfg.DatabaseUri) if err != nil { - slog.Error("couldn't open database: %w", "err", err) - os.Exit(1) - } - defer db.Close() - - if err := db.Migrate(ctx); err != nil { - slog.Error("migration failed: %w", "err", err) - os.Exit(1) + slog.Error("couldn't open database", "err", err) + return err } - slog.Info("database ready") - srv := &server{ db: db, cfg: cfg, forge: &forges.GitHub{Secret: cfg.WebhookSecret, Token: cfg.GitHubToken}, - queue: make(chan *pb.Task, 100), + queue: make(chan *pb.Task, config.TaskQueueCapacity), } + defer srv.Close() + + if err := db.Migrate(ctx); err != nil { + slog.Error("migration failed", "err", err) + return err + } + + slog.Info("database ready") go srv.PurgeSessions(ctx) @@ -312,11 +115,7 @@ func daemon(configFile, socketFlag string) { adminSrv := hardenServer(&http.Server{ Handler: adminMux, ConnContext: func(ctx context.Context, _ net.Conn) context.Context { - return context.WithValue(ctx, callerKey{}, &callerInfo{ - UserID: uuid.Nil, - Email: "root@localhost", - Superuser: true, - }) + return context.WithValue(ctx, actorKey{}, database.OperatorActor()) }, BaseContext: func(_ net.Listener) context.Context { return ctx @@ -325,67 +124,76 @@ func daemon(configFile, socketFlag string) { grpcLn, webLn, adminLn, err := listeners(cfg) if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + slog.Error("listeners failed", "err", err) + return err } slog.Info("listening", "grpc", grpcLn.Addr(), "web", webLn.Addr(), "admin", cfg.AdminSocket) - go func() { - var err error + errs := make(chan error, 3) + serve := func(name string, fn func() error) { + go func() { + err := fn() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + if err != nil { + err = fmt.Errorf("%s server: %w", name, err) + } + errs <- err + }() + } + serve("web", func() error { if webSrv.TLSConfig != nil { - err = webSrv.ServeTLS(webLn, "", "") - } else { - err = webSrv.Serve(webLn) - } - if err != nil && err != http.ErrServerClosed { - slog.Error("web server failed", "err", err) - os.Exit(1) - } - }() - go func() { - if err := grpcSrv.ServeTLS(grpcLn, "", ""); err != nil && err != http.ErrServerClosed { - slog.Error("grpc server failed", "err", err) - os.Exit(1) - } - }() - go func() { - if err := adminSrv.Serve(adminLn); err != nil && err != http.ErrServerClosed { - slog.Error("admin server failed", "err", err) - os.Exit(1) + return webSrv.ServeTLS(webLn, "", "") } - }() + return webSrv.Serve(webLn) + }) + serve("grpc", func() error { return grpcSrv.ServeTLS(grpcLn, "", "") }) + serve("admin", func() error { return adminSrv.Serve(adminLn) }) sup.Ready() go sup.StartWatchdog(ctx) - <-ctx.Done() - slog.Info("shutting down") - sup.Stopping() + var runErr error + select { + case <-ctx.Done(): + slog.Info("shutting down") + case err := <-errs: + runErr = err + // Propagate the crash to all handler contexts so Poll and + // other long-lived RPCs exit via ctx.Done(); Shutdown below + // then completes without waiting on them. + cancel() + if err != nil { + slog.Error("server exited, shutting down peers", "err", err) + } else { + slog.Warn("server exited unexpectedly, shutting down peers") + } + } - // Hard deadline: if graceful shutdown takes too long, exit. - time.AfterFunc(30*time.Second, func() { - slog.Error("shutdown timed out, forcing exit") - os.Exit(1) - }) + sup.Stopping() - srv.Close() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), config.HTTPShutdownTimeout) + defer shutdownCancel() - if err := webSrv.Shutdown(context.Background()); err != nil { - slog.Error("web server shutdown", "err", err) - } - if err := grpcSrv.Shutdown(context.Background()); err != nil { - slog.Error("grpc server shutdown", "err", err) - } - if err := adminSrv.Shutdown(context.Background()); err != nil { - slog.Error("admin server shutdown", "err", err) + shutdown := func(name string, s *http.Server) { + if err := s.Shutdown(shutdownCtx); err != nil { + slog.Error("server shutdown", "name", name, "err", err) + } } + + shutdown("web", webSrv) + shutdown("grpc", grpcSrv) + shutdown("admin", adminSrv) + + return runErr } // listeners returns gRPC, web, and admin listeners. // With systemd socket activation it expects two named fds: "grpc" and "web". // Without socket activation it falls back to configured addresses. -func listeners(cfg *config) (grpcLn, webLn, adminLn net.Listener, err error) { +func listeners(cfg *appConfig) (grpcLn, webLn, adminLn net.Listener, err error) { named, err := activation.ListenersWithNames() if err != nil { return nil, nil, nil, fmt.Errorf("socket activation: %w", err) @@ -445,225 +253,3 @@ func adminClient(socketPath string) pbconnect.AdminClient { "http://localhost.unix", ) } - -func cliUserRef(email string) *pb.UserRef { - return &pb.UserRef{Ref: &pb.UserRef_Email{Email: email}} -} - -func cliOrgRef(slug string) *pb.OrgRef { - return &pb.OrgRef{Ref: &pb.OrgRef_Slug{Slug: slug}} -} - -func cliRoleToProto(role string) pb.Role { - switch role { - case "owner": - return pb.Role_ROLE_OWNER - case "admin": - return pb.Role_ROLE_ADMIN - case "member": - return pb.Role_ROLE_MEMBER - default: - return pb.Role_ROLE_NONE - } -} - -func cliUUID(b []byte) string { - if len(b) == 16 { - u, _ := uuid.FromBytes(b) - return u.String() - } - return fmt.Sprintf("%x", b) -} - -func userCreate(socketPath, email, password string) { - resp, err := adminClient(socketPath).UserCreate(context.Background(), connect.NewRequest(&pb.UserCreateRequest{ - Email: email, - Password: password, - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println(cliUUID(resp.Msg.Id)) -} - -func userSetPassword(socketPath, email, password string) { - _, err := adminClient(socketPath).UserUpdate(context.Background(), connect.NewRequest(&pb.UserUpdateRequest{ - User: cliUserRef(email), - Password: &password, - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println("ok") -} - -func userDelete(socketPath, email string) { - _, err := adminClient(socketPath).UserDelete(context.Background(), connect.NewRequest(&pb.UserDeleteRequest{ - User: cliUserRef(email), - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println("ok") -} - -func workerAdd(socketPath, pubkeyB64 string) { - der, err := base64.StdEncoding.DecodeString(pubkeyB64) - if err != nil { - fmt.Fprintln(os.Stderr, "invalid base64:", err) - os.Exit(1) - } - pubkey, err := x509.ParsePKIXPublicKey(der) - if err != nil { - fmt.Fprintln(os.Stderr, "invalid public key:", err) - os.Exit(1) - } - edKey, ok := pubkey.(ed25519.PublicKey) - if !ok { - fmt.Fprintln(os.Stderr, "not an ed25519 key") - os.Exit(1) - } - resp, err := adminClient(socketPath).WorkerCreate(context.Background(), connect.NewRequest(&pb.WorkerCreateRequest{ - PublicKey: edKey, - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println(cliUUID(resp.Msg.Id)) -} - -func workerRevoke(socketPath, id string) { - uid, err := uuid.Parse(id) - if err != nil { - fmt.Fprintln(os.Stderr, "invalid uuid:", err) - os.Exit(1) - } - _, err = adminClient(socketPath).WorkerDelete(context.Background(), connect.NewRequest(&pb.WorkerDeleteRequest{ - Id: uid[:], - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println("ok") -} - -func workerList(socketPath string) { - resp, err := adminClient(socketPath).WorkerList(context.Background(), connect.NewRequest(&pb.WorkerListRequest{})) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - for _, w := range resp.Msg.Workers { - created := w.CreatedAt.AsTime().Format(time.DateOnly) - fmt.Printf("%s\t%s\t%s\n", cliUUID(w.Id), base64.StdEncoding.EncodeToString(w.PublicKey), created) - } -} - -func orgCreate(socketPath, name, slug string, public bool, ownerEmail string) { - resp, err := adminClient(socketPath).OrgCreate(context.Background(), connect.NewRequest(&pb.OrgCreateRequest{ - Name: name, - Slug: slug, - Public: public, - Owner: cliUserRef(ownerEmail), - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println(cliUUID(resp.Msg.Id)) -} - -func orgDelete(socketPath, slug string) { - _, err := adminClient(socketPath).OrgDelete(context.Background(), connect.NewRequest(&pb.OrgDeleteRequest{ - Org: cliOrgRef(slug), - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println("ok") -} - -func orgRename(socketPath, slug, newName, newSlug string) { - _, err := adminClient(socketPath).OrgUpdate(context.Background(), connect.NewRequest(&pb.OrgUpdateRequest{ - Org: cliOrgRef(slug), - Name: &newName, - Slug: &newSlug, - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println("ok") -} - -func orgList(socketPath string) { - resp, err := adminClient(socketPath).OrgList(context.Background(), connect.NewRequest(&pb.OrgListRequest{})) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - for _, o := range resp.Msg.Organizations { - visibility := "private" - if o.Public { - visibility = "public" - } - fmt.Printf("%s\t%s\t%s\t%s\n", o.Slug, o.Name, visibility, o.CreatedAt.AsTime().Format(time.DateOnly)) - } -} - -func orgMemberAdd(socketPath, orgSlug, email, role string) { - _, err := adminClient(socketPath).OrgMemberAdd(context.Background(), connect.NewRequest(&pb.OrgMemberAddRequest{ - Org: cliOrgRef(orgSlug), - User: cliUserRef(email), - Role: cliRoleToProto(role), - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println("ok") -} - -func orgMemberRemove(socketPath, orgSlug, email string) { - _, err := adminClient(socketPath).OrgMemberRemove(context.Background(), connect.NewRequest(&pb.OrgMemberRemoveRequest{ - Org: cliOrgRef(orgSlug), - User: cliUserRef(email), - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println("ok") -} - -func orgMemberSetRole(socketPath, orgSlug, email, role string) { - _, err := adminClient(socketPath).OrgMemberUpdate(context.Background(), connect.NewRequest(&pb.OrgMemberUpdateRequest{ - Org: cliOrgRef(orgSlug), - User: cliUserRef(email), - Role: cliRoleToProto(role), - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println("ok") -} - -func orgMemberList(socketPath, orgSlug string) { - resp, err := adminClient(socketPath).OrgMemberList(context.Background(), connect.NewRequest(&pb.OrgMemberListRequest{ - Org: cliOrgRef(orgSlug), - })) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - for _, m := range resp.Msg.Members { - fmt.Printf("%s\t%s\t%s\n", m.User.Email, m.Role, m.JoinedAt.AsTime().Format(time.DateOnly)) - } -} diff --git a/cmd/mirumd/server.go b/cmd/mirumd/server.go index a93c9b8..3a86052 100644 --- a/cmd/mirumd/server.go +++ b/cmd/mirumd/server.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "time" + "dimidiumlabs/mirum/internal/config" "dimidiumlabs/mirum/internal/database" "dimidiumlabs/mirum/internal/forges" "dimidiumlabs/mirum/internal/protocol/pb" @@ -18,7 +19,7 @@ import ( // server holds the shared application state. type server struct { - cfg *config + cfg *appConfig db *database.DB forge forges.Forge @@ -27,13 +28,16 @@ type server struct { taskCounter atomic.Int64 } +// Close releases resources owned by the server. Call exactly once, after +// all HTTP servers have finished Shutdown. func (s *server) Close() { close(s.queue) + s.db.Close() } // PurgeSessions periodically deletes expired sessions until ctx is cancelled. func (s *server) PurgeSessions(ctx context.Context) { - ticker := time.NewTicker(1 * time.Hour) + ticker := time.NewTicker(config.SessionPurgeInterval) defer ticker.Stop() for { diff --git a/cmd/mirumd/server_admin.go b/cmd/mirumd/server_admin.go index 3bf8765..f758c0f 100644 --- a/cmd/mirumd/server_admin.go +++ b/cmd/mirumd/server_admin.go @@ -6,7 +6,7 @@ package main import ( "context" "errors" - "fmt" + "log/slog" "net/http" "connectrpc.com/connect" @@ -34,46 +34,50 @@ type adminService struct { srv *server } -// anonActorID is a UUID that is not a real user — used for unauthenticated -// public requests. RLS will show only public data for this actor. -var anonActorID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff") +// --- Error mapping --- -// actorID returns the authenticated user's UUID from context, -// or anonActorID for unauthenticated public requests. -func actorID(ctx context.Context) uuid.UUID { - if c := CallerFromContext(ctx); c != nil { - return c.UserID - } - return anonActorID +// newAPIError builds a ConnectError with an empty message string and attaches +// an ErrorInfo detail carrying the domain reason. Clients switch on reason to +// pick user-facing text; the wire never carries human-readable strings. +func newAPIError(code connect.Code, reason pb.ErrorReason, metadata map[string]string) error { + e := connect.NewError(code, nil) + if d, err := connect.NewErrorDetail(&pb.ErrorInfo{Reason: reason, Metadata: metadata}); err == nil { + e.AddDetail(d) + } + return e +} + +var errSpecs = []struct { + err error + code connect.Code + reason pb.ErrorReason +}{ + {database.ErrUserNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_USER_NOT_FOUND}, + {database.ErrOrgNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_ORG_NOT_FOUND}, + {database.ErrWorkerNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_WORKER_NOT_FOUND}, + {database.ErrNotMember, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_MEMBER_NOT_FOUND}, + {database.ErrEmailTaken, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_EMAIL_TAKEN}, + {database.ErrSlugTaken, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_SLUG_TAKEN}, + {database.ErrAlreadyMember, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_ALREADY_MEMBER}, + {database.ErrLastOwner, connect.CodeFailedPrecondition, pb.ErrorReason_ERROR_REASON_LAST_OWNER}, + {database.ErrSoleOwner, connect.CodeFailedPrecondition, pb.ErrorReason_ERROR_REASON_SOLE_OWNER}, + {database.ErrInvalidSlug, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_SLUG}, + {database.ErrInvalidRole, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE}, + {database.ErrReservedEmail, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_RESERVED_EMAIL}, + {database.ErrFilterNotImplemented, connect.CodeUnimplemented, pb.ErrorReason_ERROR_REASON_UNIMPLEMENTED}, } -// --- Error mapping --- - func mapErr(err error) error { if err == nil { return nil } - switch { - case errors.Is(err, database.ErrUserNotFound), - errors.Is(err, database.ErrOrgNotFound), - errors.Is(err, database.ErrWorkerNotFound), - errors.Is(err, database.ErrNotMember): - return connect.NewError(connect.CodeNotFound, err) - case errors.Is(err, database.ErrAlreadyMember), - errors.Is(err, database.ErrSlugTaken), - errors.Is(err, database.ErrEmailTaken): - return connect.NewError(connect.CodeAlreadyExists, err) - case errors.Is(err, database.ErrLastOwner), - errors.Is(err, database.ErrSoleOwner): - return connect.NewError(connect.CodeFailedPrecondition, err) - case errors.Is(err, database.ErrInvalidSlug), - errors.Is(err, database.ErrInvalidRole): - return connect.NewError(connect.CodeInvalidArgument, err) - case errors.Is(err, database.ErrFilterNotImplemented): - return connect.NewError(connect.CodeUnimplemented, err) - default: - return connect.NewError(connect.CodeInternal, fmt.Errorf("internal error")) + for _, s := range errSpecs { + if errors.Is(err, s.err) { + return newAPIError(s.code, s.reason, nil) + } } + slog.Error("unmapped handler error", "err", err) + return newAPIError(connect.CodeInternal, pb.ErrorReason_ERROR_REASON_INTERNAL, nil) } // --- Ref converters --- @@ -179,7 +183,7 @@ func workerToProto(w database.Worker) *pb.Worker { // --- User handlers --- func (a *adminService) UserCreate(ctx context.Context, req *connect.Request[pb.UserCreateRequest]) (*connect.Response[pb.UserCreateResponse], error) { - id, err := a.srv.db.UserCreate(ctx, actorID(ctx), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)) + id, err := a.srv.db.UserCreate(ctx, ActorFromContext(ctx), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)) if err != nil { return nil, mapErr(err) } @@ -187,7 +191,7 @@ func (a *adminService) UserCreate(ctx context.Context, req *connect.Request[pb.U } func (a *adminService) UserGet(ctx context.Context, req *connect.Request[pb.UserGetRequest]) (*connect.Response[pb.UserGetResponse], error) { - u, err := a.srv.db.GetUser(ctx, actorID(ctx), userRef(req.Msg.User)) + u, err := a.srv.db.GetUser(ctx, ActorFromContext(ctx), userRef(req.Msg.User)) if err != nil { return nil, mapErr(err) } @@ -201,7 +205,7 @@ func (a *adminService) UserList(ctx context.Context, req *connect.Request[pb.Use filter = *req.Msg.Filter } - users, total, err := a.srv.db.ListUsers(ctx, actorID(ctx), cursor, limit, filter) + users, total, err := a.srv.db.ListUsers(ctx, ActorFromContext(ctx), cursor, limit, filter) if err != nil { return nil, mapErr(err) } @@ -223,14 +227,14 @@ func (a *adminService) UserList(ctx context.Context, req *connect.Request[pb.Use } func (a *adminService) UserUpdate(ctx context.Context, req *connect.Request[pb.UserUpdateRequest]) (*connect.Response[pb.UserUpdateResponse], error) { - if err := a.srv.db.UserUpdate(ctx, actorID(ctx), userRef(req.Msg.User), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil { + if err := a.srv.db.UserUpdate(ctx, ActorFromContext(ctx), userRef(req.Msg.User), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil { return nil, mapErr(err) } return connect.NewResponse(&pb.UserUpdateResponse{}), nil } func (a *adminService) UserDelete(ctx context.Context, req *connect.Request[pb.UserDeleteRequest]) (*connect.Response[pb.UserDeleteResponse], error) { - if err := a.srv.db.UserDelete(ctx, actorID(ctx), userRef(req.Msg.User)); err != nil { + if err := a.srv.db.UserDelete(ctx, ActorFromContext(ctx), userRef(req.Msg.User)); err != nil { return nil, mapErr(err) } return connect.NewResponse(&pb.UserDeleteResponse{}), nil @@ -243,7 +247,7 @@ func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.Or if err != nil { return nil, mapErr(err) } - id, err := a.srv.db.CreateOrganization(ctx, actorID(ctx), req.Msg.Name, slug, req.Msg.Public, userRef(req.Msg.Owner)) + id, err := a.srv.db.CreateOrganization(ctx, ActorFromContext(ctx), req.Msg.Name, slug, req.Msg.Public, userRef(req.Msg.Owner)) if err != nil { return nil, mapErr(err) } @@ -251,7 +255,7 @@ func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.Or } func (a *adminService) OrgGet(ctx context.Context, req *connect.Request[pb.OrgGetRequest]) (*connect.Response[pb.OrgGetResponse], error) { - o, err := a.srv.db.GetOrg(ctx, actorID(ctx), orgRef(req.Msg.Org)) + o, err := a.srv.db.GetOrg(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org)) if err != nil { return nil, mapErr(err) } @@ -265,7 +269,7 @@ func (a *adminService) OrgList(ctx context.Context, req *connect.Request[pb.OrgL filter = *req.Msg.Filter } - orgs, total, err := a.srv.db.ListOrganizations(ctx, actorID(ctx), cursor, limit, filter) + orgs, total, err := a.srv.db.ListOrganizations(ctx, ActorFromContext(ctx), cursor, limit, filter) if err != nil { return nil, mapErr(err) } @@ -295,14 +299,14 @@ func (a *adminService) OrgUpdate(ctx context.Context, req *connect.Request[pb.Or } slug = &s } - if err := a.srv.db.UpdateOrganization(ctx, actorID(ctx), orgRef(req.Msg.Org), req.Msg.Name, slug, req.Msg.Public); err != nil { + if err := a.srv.db.UpdateOrganization(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), req.Msg.Name, slug, req.Msg.Public); err != nil { return nil, mapErr(err) } return connect.NewResponse(&pb.OrgUpdateResponse{}), nil } func (a *adminService) OrgDelete(ctx context.Context, req *connect.Request[pb.OrgDeleteRequest]) (*connect.Response[pb.OrgDeleteResponse], error) { - if err := a.srv.db.DeleteOrganization(ctx, actorID(ctx), orgRef(req.Msg.Org)); err != nil { + if err := a.srv.db.DeleteOrganization(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org)); err != nil { return nil, mapErr(err) } return connect.NewResponse(&pb.OrgDeleteResponse{}), nil @@ -313,16 +317,16 @@ func (a *adminService) OrgDelete(ctx context.Context, req *connect.Request[pb.Or func (a *adminService) OrgMemberAdd(ctx context.Context, req *connect.Request[pb.OrgMemberAddRequest]) (*connect.Response[pb.OrgMemberAddResponse], error) { role, ok := roleToString[req.Msg.Role] if !ok { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid role")) + return nil, newAPIError(connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil) } - if err := a.srv.db.AddOrgMember(ctx, actorID(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil { + if err := a.srv.db.AddOrgMember(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil { return nil, mapErr(err) } return connect.NewResponse(&pb.OrgMemberAddResponse{}), nil } func (a *adminService) OrgMemberGet(ctx context.Context, req *connect.Request[pb.OrgMemberGetRequest]) (*connect.Response[pb.OrgMemberGetResponse], error) { - m, err := a.srv.db.GetOrgMember(ctx, actorID(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User)) + m, err := a.srv.db.GetOrgMember(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User)) if err != nil { return nil, mapErr(err) } @@ -336,7 +340,7 @@ func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[p filter = *req.Msg.Filter } - members, total, err := a.srv.db.ListOrgMembers(ctx, actorID(ctx), orgRef(req.Msg.Org), cursor, limit, filter) + members, total, err := a.srv.db.ListOrgMembers(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), cursor, limit, filter) if err != nil { return nil, mapErr(err) } @@ -360,16 +364,16 @@ func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[p func (a *adminService) OrgMemberUpdate(ctx context.Context, req *connect.Request[pb.OrgMemberUpdateRequest]) (*connect.Response[pb.OrgMemberUpdateResponse], error) { role, ok := roleToString[req.Msg.Role] if !ok { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid role")) + return nil, newAPIError(connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil) } - if err := a.srv.db.UpdateOrgMemberRole(ctx, actorID(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil { + if err := a.srv.db.UpdateOrgMemberRole(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User), role); err != nil { return nil, mapErr(err) } return connect.NewResponse(&pb.OrgMemberUpdateResponse{}), nil } func (a *adminService) OrgMemberRemove(ctx context.Context, req *connect.Request[pb.OrgMemberRemoveRequest]) (*connect.Response[pb.OrgMemberRemoveResponse], error) { - if err := a.srv.db.RemoveOrgMember(ctx, actorID(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User)); err != nil { + if err := a.srv.db.RemoveOrgMember(ctx, ActorFromContext(ctx), orgRef(req.Msg.Org), userRef(req.Msg.User)); err != nil { return nil, mapErr(err) } return connect.NewResponse(&pb.OrgMemberRemoveResponse{}), nil @@ -383,7 +387,7 @@ func (a *adminService) WorkerCreate(ctx context.Context, req *connect.Request[pb r := orgRef(req.Msg.Org) org = &r } - id, err := a.srv.db.CreateWorker(ctx, actorID(ctx), req.Msg.PublicKey, org) + id, err := a.srv.db.CreateWorker(ctx, ActorFromContext(ctx), req.Msg.PublicKey, org) if err != nil { return nil, mapErr(err) } @@ -391,7 +395,7 @@ func (a *adminService) WorkerCreate(ctx context.Context, req *connect.Request[pb } func (a *adminService) WorkerGet(ctx context.Context, req *connect.Request[pb.WorkerGetRequest]) (*connect.Response[pb.WorkerGetResponse], error) { - w, err := a.srv.db.GetWorker(ctx, actorID(ctx), uuid.UUID(req.Msg.Id)) + w, err := a.srv.db.GetWorker(ctx, ActorFromContext(ctx), uuid.UUID(req.Msg.Id)) if err != nil { return nil, mapErr(err) } @@ -405,7 +409,7 @@ func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.W filter = *req.Msg.Filter } - workers, total, err := a.srv.db.ListWorkers(ctx, actorID(ctx), cursor, limit, filter) + workers, total, err := a.srv.db.ListWorkers(ctx, ActorFromContext(ctx), cursor, limit, filter) if err != nil { return nil, mapErr(err) } @@ -427,7 +431,7 @@ func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.W } func (a *adminService) WorkerDelete(ctx context.Context, req *connect.Request[pb.WorkerDeleteRequest]) (*connect.Response[pb.WorkerDeleteResponse], error) { - if err := a.srv.db.DeleteWorker(ctx, actorID(ctx), uuid.UUID(req.Msg.Id)); err != nil { + if err := a.srv.db.DeleteWorker(ctx, ActorFromContext(ctx), uuid.UUID(req.Msg.Id)); err != nil { return nil, mapErr(err) } return connect.NewResponse(&pb.WorkerDeleteResponse{}), nil diff --git a/cmd/mirumd/server_grpc.go b/cmd/mirumd/server_grpc.go index 5193457..580154e 100644 --- a/cmd/mirumd/server_grpc.go +++ b/cmd/mirumd/server_grpc.go @@ -17,8 +17,9 @@ import ( "connectrpc.com/connect" "connectrpc.com/validate" - "github.com/google/uuid" + "dimidiumlabs/mirum/internal/config" + "dimidiumlabs/mirum/internal/database" "dimidiumlabs/mirum/internal/protocol" "dimidiumlabs/mirum/internal/protocol/pb" "dimidiumlabs/mirum/internal/protocol/pb/pbconnect" @@ -34,19 +35,18 @@ func NewGrpcServer(ctx context.Context, srv *server) *http.Server { mux := http.NewServeMux() mux.Handle(path, workerLog(handler)) + certs := newCertReloader(srv.cfg.GrpcTls.Cert, srv.cfg.GrpcTls.Key) + return &http.Server{ Handler: mux, BaseContext: func(_ net.Listener) context.Context { return ctx }, TLSConfig: &tls.Config{ - NextProtos: []string{"h2"}, - MinVersion: tls.VersionTLS13, - ClientAuth: tls.RequireAnyClientCert, - GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := tls.LoadX509KeyPair(srv.cfg.GrpcTls.Cert, srv.cfg.GrpcTls.Key) - return &cert, err - }, + NextProtos: []string{"h2"}, + MinVersion: tls.VersionTLS13, + ClientAuth: tls.RequireAnyClientCert, + GetCertificate: certs.GetCertificate, VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { if len(rawCerts) == 0 { return errors.New("client certificate required") @@ -62,14 +62,14 @@ func NewGrpcServer(ctx context.Context, srv *server) *http.Server { return errors.New("ed25519 certificate required") } - if _, err := srv.db.LookupWorker(context.Background(), uuid.Nil, pubKey); err != nil { + if _, err := srv.db.LookupWorker(context.Background(), database.SystemActor(), pubKey); err != nil { return fmt.Errorf("unknown worker: %w", err) } // Clock skew: NotBefore is set to time.Now() when the cert was generated. // Checked here (once per TLS handshake), not in the interceptor, // because HTTP/2 reuses the connection and NotBefore would go stale. - if skew := time.Since(c.NotBefore).Abs(); skew > time.Minute { + if skew := time.Since(c.NotBefore).Abs(); skew > config.WorkerClockSkewLimit { return fmt.Errorf("%w: %s", protocol.ErrClockSkew, skew.Truncate(time.Second)) } diff --git a/cmd/mirumd/server_web.go b/cmd/mirumd/server_web.go index 7ce1a7f..877d700 100644 --- a/cmd/mirumd/server_web.go +++ b/cmd/mirumd/server_web.go @@ -11,19 +11,20 @@ import ( "encoding/base64" "errors" "io" + "log/slog" "net" "net/http" + "runtime/debug" "strings" - "time" - - "github.com/google/uuid" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" + "dimidiumlabs/mirum/internal/config" "dimidiumlabs/mirum/internal/database" "dimidiumlabs/mirum/internal/forges" + "dimidiumlabs/mirum/internal/protocol/pb" ) // __Host- prefixed cookies can only be set with Secure, Path=/, and no @@ -46,11 +47,11 @@ func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandl r.Use(middleware.StripSlashes) r.Use(middleware.RequestID) r.Use(middleware.Logger) - r.Use(middleware.Recoverer) + r.Use(h.recoverer) r.Use(middleware.Compress(5)) r.Use(middleware.Heartbeat("/ping")) - r.Use(middleware.Timeout(30 * time.Second)) - r.Use(middleware.RequestSize(64 << 20)) // 64 MiB global body limit + r.Use(middleware.Timeout(config.WebRequestTimeout)) + r.Use(middleware.RequestSize(config.WebMaxBodyBytes)) r.Use(trustedProxyMiddleware(srv.cfg.TrustedProxies)) r.Use(func(next http.Handler) http.Handler { @@ -81,25 +82,30 @@ func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandl r.Route("/auth", func(r chi.Router) { r.Use(middleware.NoCache) - r.Use(httprate.LimitByIP(10, time.Minute)) - r.Use(middleware.RequestSize(4096)) + r.Use(httprate.LimitByIP(config.AuthRateLimit, config.AuthRateWindow)) + r.Use(middleware.RequestSize(config.AuthMaxBodyBytes)) r.Get("/login", h.loginPage) r.Post("/login", h.login) r.Post("/logout", h.logout) }) - r.With(middleware.NoCache, httprate.LimitByIP(300, time.Minute)). + r.With(middleware.NoCache, httprate.LimitByIP(config.APIRateLimit, config.APIRateWindow)). Mount("/api/v1", http.StripPrefix("/api/v1", adminHandler)) + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + h.renderError(w, r, http.StatusNotFound) + }) + r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { + h.renderError(w, r, http.StatusMethodNotAllowed) + }) + var tlsCfg *tls.Config if srv.cfg.WebTls != nil { + certs := newCertReloader(srv.cfg.WebTls.Cert, srv.cfg.WebTls.Key) tlsCfg = &tls.Config{ - MinVersion: tls.VersionTLS13, - GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := tls.LoadX509KeyPair(srv.cfg.WebTls.Cert, srv.cfg.WebTls.Key) - return &cert, err - }, + MinVersion: tls.VersionTLS13, + GetCertificate: certs.GetCertificate, } } @@ -112,38 +118,27 @@ func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandl } } -type callerKey struct{} - -type callerInfo struct { - UserID uuid.UUID - Email string - Superuser bool -} +type actorKey struct{} type webHandler struct { srv *server assets *assetResolver } -// CallerFromContext returns the authenticated caller, or nil. -func CallerFromContext(ctx context.Context) *callerInfo { - if v, ok := ctx.Value(callerKey{}).(*callerInfo); ok { +// ActorFromContext returns the authenticated actor, or AnonActor if none. +func ActorFromContext(ctx context.Context) database.Actor { + if v, ok := ctx.Value(actorKey{}).(database.Actor); ok { return v } - return nil + return database.AnonActor() } -// SessionMiddleware resolves the session cookie and puts callerInfo in context. +// SessionMiddleware resolves the session cookie and puts the Actor in context. func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil { - if sess, err := h.srv.db.UserGetSession(r.Context(), uuid.Nil, c.Value); err == nil { - caller := &callerInfo{ - UserID: sess.UserID, - Email: sess.Email, - Superuser: sess.Superuser, - } - ctx := context.WithValue(r.Context(), callerKey{}, caller) + if actor, err := h.srv.db.UserGetSession(r.Context(), database.SystemActor(), c.Value); err == nil { + ctx := context.WithValue(r.Context(), actorKey{}, actor) r = r.WithContext(ctx) } } @@ -151,26 +146,56 @@ func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler { }) } -type authedHandler func(w http.ResponseWriter, r *http.Request, caller *callerInfo) +type authedHandler func(w http.ResponseWriter, r *http.Request, actor database.Actor) func authonly(next authedHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - caller := CallerFromContext(r.Context()) - if caller == nil { + actor := ActorFromContext(r.Context()) + if actor.Kind() == database.KindAnon { http.Redirect(w, r, "/auth/login", http.StatusSeeOther) return } - next(w, r, caller) + next(w, r, actor) } } -func (h *webHandler) index(w http.ResponseWriter, r *http.Request, caller *callerInfo) { - h.assets.renderPage(w, "dashboard", map[string]any{ - "user": map[string]string{"email": caller.Email}, +func (h *webHandler) index(w http.ResponseWriter, r *http.Request, actor database.Actor) { + h.assets.renderPage(w, "dashboard", http.StatusOK, map[string]any{ + "user": map[string]string{"email": actor.Email()}, "csrf": csrfToken(w, r), }) } +// renderError serves the error page with the given HTTP status. +func (h *webHandler) renderError(w http.ResponseWriter, r *http.Request, status int) { + h.assets.renderPage(w, "error", status, map[string]any{"status": status}) +} + +// recoverer catches panics, logs them, and renders the 500 page so the +// client sees something more useful than chi's plaintext default. The +// http.ErrAbortHandler sentinel is re-raised so net/http's server +// machinery can recognise an intentional handler abort. +func (h *webHandler) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rvr := recover() + if rvr == nil { + return + } + if rvr == http.ErrAbortHandler { + panic(rvr) + } + slog.Error("panic", + "err", rvr, + "path", r.URL.Path, + "stack", string(debug.Stack()), + ) + h.renderError(w, r, http.StatusInternalServerError) + }() + next.ServeHTTP(w, r) + }) +} + func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { @@ -197,34 +222,40 @@ func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) { } func (h *webHandler) loginPage(w http.ResponseWriter, r *http.Request) { - h.assets.renderPage(w, "login", map[string]any{ - "csrf": csrfToken(w, r), - }) + h.renderLogin(w, r, http.StatusOK, pb.ErrorReason_ERROR_REASON_UNSPECIFIED) +} + +// renderLogin is the single entry point for every login-flow outcome that +// lands back on the login page. Reason == UNSPECIFIED means no error banner. +// No caller writes error text itself — the client maps reason → copy. +func (h *webHandler) renderLogin(w http.ResponseWriter, r *http.Request, status int, reason pb.ErrorReason) { + data := map[string]any{"csrf": csrfToken(w, r)} + if reason != pb.ErrorReason_ERROR_REASON_UNSPECIFIED { + data["errorReason"] = int32(reason) + } + h.assets.renderPage(w, "login", status, data) } func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { if !csrfOK(r) { clearCookie(w, csrfCookie) - http.Error(w, "invalid csrf token", http.StatusForbidden) + h.renderLogin(w, r, http.StatusForbidden, pb.ErrorReason_ERROR_REASON_INVALID_CSRF) return } email := r.FormValue("email") password := r.FormValue("password") - userID, err := h.srv.db.UserVerifyPassword(r.Context(), uuid.Nil, email, password, []byte(h.srv.cfg.Pepper)) + userID, err := h.srv.db.UserVerifyPassword(r.Context(), database.SystemActor(), email, password, []byte(h.srv.cfg.Pepper)) if err != nil { - w.WriteHeader(http.StatusUnauthorized) - h.assets.renderPage(w, "login", map[string]any{ - "csrf": csrfToken(w, r), - "error": "Wrong email or password. Please try again.", - }) + h.renderLogin(w, r, http.StatusUnauthorized, pb.ErrorReason_ERROR_REASON_INVALID_CREDENTIALS) return } - token, err := h.srv.db.UserCreateSession(r.Context(), uuid.Nil, userID) + token, err := h.srv.db.UserCreateSession(r.Context(), database.SystemActor(), userID) if err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) + slog.Error("create session failed", "err", err) + h.renderLogin(w, r, http.StatusInternalServerError, pb.ErrorReason_ERROR_REASON_INTERNAL) return } @@ -235,18 +266,20 @@ func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, - MaxAge: int(database.SessionTTL.Seconds()), + MaxAge: int(config.SessionTTL.Seconds()), }) http.Redirect(w, r, "/", http.StatusSeeOther) } func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { if !csrfOK(r) { - http.Error(w, "invalid csrf token", http.StatusForbidden) + // Forged logout attempt — ignore silently. Session stays valid, + // user ends up wherever / takes them. + http.Redirect(w, r, "/", http.StatusSeeOther) return } if c, err := r.Cookie(sessionCookie); err == nil { - h.srv.db.UserDeleteSession(r.Context(), uuid.Nil, c.Value) + h.srv.db.UserDeleteSession(r.Context(), database.SystemActor(), c.Value) } clearCookie(w, sessionCookie) clearCookie(w, csrfCookie) @@ -270,6 +303,7 @@ func csrfToken(w http.ResponseWriter, r *http.Request) string { HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, + MaxAge: int(config.SessionTTL.Seconds()), }) return token } diff --git a/cmd/mirumd/static.go b/cmd/mirumd/static.go index 3a23159..bc304ba 100644 --- a/cmd/mirumd/static.go +++ b/cmd/mirumd/static.go @@ -183,7 +183,7 @@ window.__vite_plugin_react_preamble_installed__ = true`, return ar.assets[name] } -func (ar *assetResolver) renderPage(w http.ResponseWriter, entry string, data any) { +func (ar *assetResolver) renderPage(w http.ResponseWriter, entry string, status int, data any) { dataJSON, err := json.Marshal(data) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) @@ -197,6 +197,9 @@ func (ar *assetResolver) renderPage(w http.ResponseWriter, entry string, data an if assets.CSP != "" { w.Header().Set("Content-Security-Policy", assets.CSP) } + if status != 0 && status != http.StatusOK { + w.WriteHeader(status) + } if err := shellTmpl.Execute(w, struct { DataJSON template.JS diff --git a/cmd/mirumd/web/api/client.ts b/cmd/mirumd/web/api/client.ts index a40e2c0..5d0ebc3 100644 --- a/cmd/mirumd/web/api/client.ts +++ b/cmd/mirumd/web/api/client.ts @@ -1,17 +1,34 @@ // Copyright (c) 2026 Nikolay Govorov // SPDX-License-Identifier: AGPL-3.0-or-later -import { createClient } from "@connectrpc/connect" +import { createClient, type Interceptor } from "@connectrpc/connect" import { createConnectTransport } from "@connectrpc/connect-web" -import { Admin } from "@/gen/admin_pb" +import { Admin, ErrorReason } from "@/gen/admin_pb" +import { errorReason } from "@/lib/errors" + +const csrfInterceptor = (csrfToken: string): Interceptor => + (next) => async (req) => { + req.header.set("X-CSRF-Token", csrfToken) + return next(req) + } + +// authInterceptor redirects to /auth/login on Unauthenticated so individual +// callers don't need to handle session expiry explicitly. +const authInterceptor: Interceptor = (next) => async (req) => { + try { + return await next(req) + } catch (err) { + if (errorReason(err) === ErrorReason.UNAUTHENTICATED) { + window.location.assign("/auth/login") + } + throw err + } +} export function createAdminClient(csrfToken: string) { const transport = createConnectTransport({ baseUrl: "/api/v1", - interceptors: [(next) => async (req) => { - req.header.set("X-CSRF-Token", csrfToken) - return next(req) - }], + interceptors: [csrfInterceptor(csrfToken), authInterceptor], }) return createClient(Admin, transport) } diff --git a/cmd/mirumd/web/components/pages/dashboard.tsx b/cmd/mirumd/web/components/pages/dashboard.tsx index 41420bc..51c2f83 100644 --- a/cmd/mirumd/web/components/pages/dashboard.tsx +++ b/cmd/mirumd/web/components/pages/dashboard.tsx @@ -1,8 +1,9 @@ // Copyright (c) 2026 Nikolay Govorov // SPDX-License-Identifier: AGPL-3.0-or-later -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { createAdminClient } from "@/api/client" +import { formatError } from "@/lib/errors" import type { Org } from "@/gen/admin_pb" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -13,16 +14,21 @@ export type DashboardProps = { } export function Page({ user, csrf }: DashboardProps) { + const client = useMemo(() => createAdminClient(csrf), [csrf]) const [orgs, setOrgs] = useState(null) const [error, setError] = useState(null) useEffect(() => { - const client = createAdminClient(csrf) + const ac = new AbortController() client - .orgList({}) + .orgList({}, { signal: ac.signal }) .then((res) => setOrgs(res.organizations)) - .catch((err: unknown) => setError(String(err))) - }, [csrf]) + .catch((err: unknown) => { + if (ac.signal.aborted) return + setError(formatError(err)) + }) + return () => ac.abort() + }, [client]) return (
diff --git a/cmd/mirumd/web/components/pages/error.tsx b/cmd/mirumd/web/components/pages/error.tsx new file mode 100644 index 0000000..9184ed2 --- /dev/null +++ b/cmd/mirumd/web/components/pages/error.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Button } from "@/components/ui/button" + +export type ErrorPageProps = { + status: number +} + +const copy: Record = { + 400: { title: "Bad request", body: "The request couldn't be processed." }, + 401: { title: "Not signed in", body: "Please sign in to continue." }, + 403: { title: "Forbidden", body: "You don't have permission to access this page." }, + 404: { title: "Not found", body: "The page you were looking for doesn't exist." }, + 405: { title: "Method not allowed", body: "That action isn't supported here." }, + 500: { title: "Something went wrong", body: "An internal error occurred. Please try again." }, + 503: { title: "Unavailable", body: "The server is temporarily unable to handle the request." }, +} + +export function Page({ status }: ErrorPageProps) { + const text = copy[status] ?? { title: `Error ${status}`, body: "Something went wrong." } + + return ( +
+

+ {status} +

+
+

+ Error {status} +

+

+ {text.title} +

+

+ {text.body} +

+ +
+
+ ) +} diff --git a/cmd/mirumd/web/components/pages/login.tsx b/cmd/mirumd/web/components/pages/login.tsx index 4e11fc6..71d07db 100644 --- a/cmd/mirumd/web/components/pages/login.tsx +++ b/cmd/mirumd/web/components/pages/login.tsx @@ -7,14 +7,18 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" +import { ErrorReason } from "@/gen/admin_pb" +import { textForReason } from "@/lib/errors" export type LoginFormProps = { csrf: string - error?: string + errorReason?: ErrorReason } -export function Page({ csrf, error: initialError }: LoginFormProps) { - const [error, setError] = useState(initialError) +export function Page({ csrf, errorReason }: LoginFormProps) { + const [error, setError] = useState( + errorReason !== undefined ? textForReason(errorReason) : undefined, + ) const dismissError = () => setError(undefined) return ( diff --git a/cmd/mirumd/web/entries/error.tsx b/cmd/mirumd/web/entries/error.tsx new file mode 100644 index 0000000..10b509e --- /dev/null +++ b/cmd/mirumd/web/entries/error.tsx @@ -0,0 +1,8 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +import "@/index.css" +import { mountPage } from "@/lib/mount" +import { Page } from "@/components/pages/error" + +mountPage(Page) diff --git a/cmd/mirumd/web/lib/errors.ts b/cmd/mirumd/web/lib/errors.ts new file mode 100644 index 0000000..cf8a20b --- /dev/null +++ b/cmd/mirumd/web/lib/errors.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Code, ConnectError } from "@connectrpc/connect" +import { ErrorInfoSchema, ErrorReason } from "@/gen/admin_pb" + +// errorReason extracts the ErrorInfo.reason attached by the server. +// Returns null for transport failures or non-mirum responses. +export function errorReason(err: unknown): ErrorReason | null { + const info = ConnectError.from(err).findDetails(ErrorInfoSchema)[0] + return info?.reason ?? null +} + +// formatError maps any thrown value to user-facing text. +// All .catch() callers should route errors through this function. +export function formatError(err: unknown): string { + const e = ConnectError.from(err) + const info = e.findDetails(ErrorInfoSchema)[0] + if (info) { + return textForReason(info.reason) + } + console.error("api error without ErrorInfo:", e) + return textForCode(e.code) +} + +export function textForReason(reason: ErrorReason): string { + switch (reason) { + case ErrorReason.USER_NOT_FOUND: + return "User not found." + case ErrorReason.ORG_NOT_FOUND: + return "Organization not found." + case ErrorReason.WORKER_NOT_FOUND: + return "Worker not found." + case ErrorReason.MEMBER_NOT_FOUND: + return "Member not found." + case ErrorReason.EMAIL_TAKEN: + return "This email is already in use." + case ErrorReason.SLUG_TAKEN: + return "This slug is already taken." + case ErrorReason.ALREADY_MEMBER: + return "Already a member of this organization." + case ErrorReason.LAST_OWNER: + return "An organization must have at least one owner." + case ErrorReason.SOLE_OWNER: + return "This user is the sole owner of an organization. Transfer ownership first." + case ErrorReason.INVALID_SLUG: + return "Invalid slug. Use lowercase letters, digits, and hyphens." + case ErrorReason.INVALID_ROLE: + return "Invalid role." + case ErrorReason.RESERVED_EMAIL: + return "This email domain is reserved. Please use a different address." + case ErrorReason.UNAUTHENTICATED: + return "Your session has expired. Please sign in again." + case ErrorReason.PERMISSION_DENIED: + return "You don't have permission to do that." + case ErrorReason.INVALID_CREDENTIALS: + return "Wrong email or password. Please try again." + case ErrorReason.INVALID_CSRF: + return "This form expired. Please try again." + case ErrorReason.RATE_LIMITED: + return "Too many requests. Please slow down." + case ErrorReason.UNAVAILABLE: + return "Service is temporarily unavailable. Please retry." + case ErrorReason.UNIMPLEMENTED: + return "This operation is not supported." + case ErrorReason.INTERNAL: + case ErrorReason.UNSPECIFIED: + default: + return "Something went wrong. Please try again." + } +} + +function textForCode(code: Code): string { + switch (code) { + case Code.Canceled: + return "Request canceled." + case Code.DeadlineExceeded: + return "Request timed out." + case Code.Unavailable: + return "Service is temporarily unavailable. Please retry." + default: + return "Connection failed. Please retry." + } +} diff --git a/cmd/mirumd/web/shell.html b/cmd/mirumd/web/shell.html index 7c21edf..ce74041 100644 --- a/cmd/mirumd/web/shell.html +++ b/cmd/mirumd/web/shell.html @@ -19,6 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
+ {{- if .Preamble}} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c9d0521 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package config holds hard-coded tunables shared across mirumd and mirumw: +// timeouts, sizes, intervals, and limits that are not (yet) exposed through +// the YAML user config. Grouping them here keeps magic numbers out of call +// sites and gives a single place to audit defaults. +package config + +import "time" + +// HTTP server hardening — applied to every *http.Server by hardenServer. +const ( + HTTPIdleTimeout = 120 * time.Second + HTTPReadHeaderTimeout = 10 * time.Second + HTTPMaxHeaderBytes = 1 << 16 // 64 KiB + HTTPShutdownTimeout = 30 * time.Second +) + +// Web router middleware — chi. +const ( + WebRequestTimeout = 30 * time.Second + WebMaxBodyBytes = 64 << 20 // 64 MiB + + // /auth routes: tighter body cap and per-IP rate limit. + AuthMaxBodyBytes = 4096 + AuthRateLimit = 10 + AuthRateWindow = time.Minute + + // /api/v1 routes: SPA-friendly per-IP rate limit. + APIRateLimit = 300 + APIRateWindow = time.Minute +) + +// Task queue — channel buffer between webhook handlers and gRPC Poll. +const TaskQueueCapacity = 100 + +// Sessions. +const ( + SessionTTL = 14 * 24 * time.Hour // cookie + DB row lifetime + SessionPurgeInterval = time.Hour // background PurgeExpiredSessions +) + +// Worker mTLS handshake. +const ( + WorkerCertLifetime = 24 * time.Hour // self-signed worker cert NotAfter + WorkerClockSkewLimit = time.Minute // max allowed skew between worker and server +) + +// Worker reconnection backoff (exponential, jittered). +const ( + WorkerBackoffMin = time.Second + WorkerBackoffMax = 60 * time.Second +) diff --git a/internal/database/actor.go b/internal/database/actor.go new file mode 100644 index 0000000..30825dc --- /dev/null +++ b/internal/database/actor.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package database + +import "github.com/google/uuid" + +// Actor is the principal making a database request. It carries identity, +// display metadata, and coarse capability. Zero value is invalid: dbID +// panics, so a missing initialisation cannot silently grant privileges. +// +// Synthetic actors (System/Operator/Anon) live only as Go constants — +// they are not rows in the users table, so they cannot be logged in as +// even if somebody writes a password into the DB. +type Actor struct { + kind actorKind + id uuid.UUID + email string + superuser bool +} + +type actorKind uint8 + +const ( + actorInvalid actorKind = iota + actorUser + actorOperator + actorSystem + actorAnon +) + +// ActorKind is the exported form of actorKind for audit sinks and logging. +type ActorKind uint8 + +const ( + KindInvalid ActorKind = iota + KindUser + KindOperator + KindSystem + KindAnon +) + +var ( + systemUUID = uuid.MustParse("00000000-0000-0000-0000-000000000001") + operatorUUID = uuid.MustParse("00000000-0000-0000-0000-000000000002") + anonUUID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff") +) + +// UserActor identifies an authenticated user from a session or token. +func UserActor(id uuid.UUID, email string, superuser bool) Actor { + if id == uuid.Nil { + panic("database: UserActor with nil UUID") + } + if email == "" { + panic("database: UserActor with empty email") + } + return Actor{kind: actorUser, id: id, email: email, superuser: superuser} +} + +// OperatorActor is the principal for externally invoked privileged +// operations (admin socket). Distinguishable from System in audit logs. +func OperatorActor() Actor { + return Actor{kind: actorOperator, id: operatorUUID, email: "operator@mirum.local", superuser: true} +} + +// SystemActor is the principal for internal machinery (mTLS handshake, +// session bootstrap, background jobs). Not an operator action. +func SystemActor() Actor { + return Actor{kind: actorSystem, id: systemUUID, email: "system@mirum.local", superuser: true} +} + +// AnonActor is the principal for unauthenticated public requests. +func AnonActor() Actor { + return Actor{kind: actorAnon, id: anonUUID, email: "anonymous@mirum.local"} +} + +func (a Actor) Kind() ActorKind { + switch a.kind { + case actorUser: + return KindUser + case actorOperator: + return KindOperator + case actorSystem: + return KindSystem + case actorAnon: + return KindAnon + } + return KindInvalid +} + +func (a Actor) UserID() uuid.UUID { return a.id } +func (a Actor) Email() string { return a.email } +func (a Actor) IsSuperuser() bool { return a.superuser } + +// dbID returns the UUID to write into app.user_id. Panics on zero value. +func (a Actor) dbID() uuid.UUID { + if a.id == uuid.Nil { + panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor") + } + return a.id +} + +// kindString returns the string written into app.actor_kind. +// It must match the values tested by app_issuper() in the SQL migration. +func (a Actor) kindString() string { + switch a.kind { + case actorUser: + return "user" + case actorOperator: + return "operator" + case actorSystem: + return "system" + case actorAnon: + return "anon" + } + panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor") +} diff --git a/internal/database/database.go b/internal/database/database.go index 7a2ff26..be01a16 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -7,7 +7,6 @@ import ( "context" "errors" - "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/tern/v2/migrate" @@ -46,13 +45,19 @@ func (db *DB) Close() { db.Pool.Close() } -// beginAs starts a transaction and sets the RLS actor. -func (db *DB) beginAs(ctx context.Context, actor uuid.UUID) (pgx.Tx, error) { +// beginAs starts a transaction and sets the RLS actor. Both app.user_id +// and app.actor_kind are populated: app_issuper() checks actor_kind for +// System/Operator principals, and app.user_id for real user superusers. +func (db *DB) beginAs(ctx context.Context, actor Actor) (pgx.Tx, error) { tx, err := db.Pool.Begin(ctx) if err != nil { return nil, err } - if _, err := tx.Exec(ctx, "SELECT set_config('app.user_id', $1, true)", actor.String()); err != nil { + if _, err := tx.Exec(ctx, + `SELECT set_config('app.user_id', $1, true), + set_config('app.actor_kind', $2, true)`, + actor.dbID().String(), actor.kindString(), + ); err != nil { tx.Rollback(ctx) return nil, err } @@ -69,8 +74,9 @@ func (db *DB) Migrate(ctx context.Context) error { // NOTE: RLS is active on all tables. Migrations with DML (INSERT/UPDATE/DELETE) // must prefix the SQL with: - // SELECT set_config('app.user_id', '00000000-0000-0000-0000-000000000000', true); - // This sets the root superuser for the migration's transaction only. + // SELECT set_config('app.actor_kind', 'system', true); + // This flips app_issuper() via the actor_kind branch for the migration's + // transaction only. No synthetic row in users is needed. migrator, err := migrate.NewMigrator(ctx, conn.Conn(), "schema_version") if err != nil { return errors.Join(ErrMigrate, err) @@ -86,19 +92,25 @@ func (db *DB) Migrate(ctx context.Context) error { deleted_at TIMESTAMPTZ ); - INSERT INTO users (id, email, password, superuser) - VALUES ('00000000-0000-0000-0000-000000000000', 'root@localhost', '', true); - CREATE FUNCTION app_user_id() RETURNS uuid STABLE AS $$ SELECT current_setting('app.user_id', true)::uuid; $$ LANGUAGE sql; + -- app_issuper has two independent branches: + -- (a) runtime setting app.actor_kind is 'system' or 'operator' — + -- set only by beginAs from Go for synthetic principals and by + -- DML migrations; cannot be injected via login since there is + -- no matching users row to authenticate against. + -- (b) the current app.user_id resolves to a users row with + -- superuser = true — real support-agent style superusers. CREATE FUNCTION app_issuper() RETURNS boolean STABLE AS $$ - SELECT EXISTS ( - SELECT 1 FROM users - WHERE id = current_setting('app.user_id', true)::uuid - AND superuser = true - ); + SELECT + current_setting('app.actor_kind', true) IN ('system', 'operator') + OR EXISTS ( + SELECT 1 FROM users + WHERE id = current_setting('app.user_id', true)::uuid + AND superuser = true + ); $$ LANGUAGE sql; `, ` DROP FUNCTION app_issuper; diff --git a/internal/database/organization.go b/internal/database/organization.go index cca7dff..e5e8a3b 100644 --- a/internal/database/organization.go +++ b/internal/database/organization.go @@ -78,7 +78,7 @@ func resolveOrg(ctx context.Context, tx pgx.Tx, ref OrgRef) (uuid.UUID, error) { } // GetOrg returns an org by ref (ID or slug). -func (db *DB) GetOrg(ctx context.Context, actor uuid.UUID, ref OrgRef) (*Organization, error) { +func (db *DB) GetOrg(ctx context.Context, actor Actor, ref OrgRef) (*Organization, error) { tx, err := db.beginAs(ctx, actor) if err != nil { return nil, err @@ -105,7 +105,7 @@ func (db *DB) GetOrg(ctx context.Context, actor uuid.UUID, ref OrgRef) (*Organiz } // CreateOrganization creates an org and adds the owner as the first member. -func (db *DB) CreateOrganization(ctx context.Context, actor uuid.UUID, name, slug string, public bool, owner UserRef) (uuid.UUID, error) { +func (db *DB) CreateOrganization(ctx context.Context, actor Actor, name, slug string, public bool, owner UserRef) (uuid.UUID, error) { tx, err := db.beginAs(ctx, actor) if err != nil { return uuid.Nil, err @@ -141,7 +141,7 @@ func (db *DB) CreateOrganization(ctx context.Context, actor uuid.UUID, name, slu } // UpdateOrganization updates an org's name, slug, and/or public flag. -func (db *DB) UpdateOrganization(ctx context.Context, actor uuid.UUID, ref OrgRef, name *string, slug *string, public *bool) error { +func (db *DB) UpdateOrganization(ctx context.Context, actor Actor, ref OrgRef, name *string, slug *string, public *bool) error { if name == nil && slug == nil && public == nil { return nil } @@ -184,7 +184,7 @@ func (db *DB) UpdateOrganization(ctx context.Context, actor uuid.UUID, ref OrgRe } // DeleteOrganization soft-deletes an org and removes all members. -func (db *DB) DeleteOrganization(ctx context.Context, actor uuid.UUID, ref OrgRef) error { +func (db *DB) DeleteOrganization(ctx context.Context, actor Actor, ref OrgRef) error { tx, err := db.beginAs(ctx, actor) if err != nil { return err @@ -210,7 +210,7 @@ func (db *DB) DeleteOrganization(ctx context.Context, actor uuid.UUID, ref OrgRe } // ListOrganizations returns a page of orgs and the total count. -func (db *DB) ListOrganizations(ctx context.Context, actor uuid.UUID, cursor uuid.UUID, limit int, filter string) ([]Organization, int, error) { +func (db *DB) ListOrganizations(ctx context.Context, actor Actor, cursor uuid.UUID, limit int, filter string) ([]Organization, int, error) { if filter != "" { return nil, 0, ErrFilterNotImplemented } @@ -257,7 +257,7 @@ func (db *DB) ListOrganizations(ctx context.Context, actor uuid.UUID, cursor uui } // GetOrgMember returns a single member's info. -func (db *DB) GetOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, user UserRef) (*OrgMember, error) { +func (db *DB) GetOrgMember(ctx context.Context, actor Actor, org OrgRef, user UserRef) (*OrgMember, error) { tx, err := db.beginAs(ctx, actor) if err != nil { return nil, err @@ -289,7 +289,7 @@ func (db *DB) GetOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, use } // ListOrgMembers returns a page of members for an org. -func (db *DB) ListOrgMembers(ctx context.Context, actor uuid.UUID, org OrgRef, cursor uuid.UUID, limit int, filter string) ([]OrgMember, int, error) { +func (db *DB) ListOrgMembers(ctx context.Context, actor Actor, org OrgRef, cursor uuid.UUID, limit int, filter string) ([]OrgMember, int, error) { if filter != "" { return nil, 0, ErrFilterNotImplemented } @@ -344,7 +344,7 @@ func (db *DB) ListOrgMembers(ctx context.Context, actor uuid.UUID, org OrgRef, c } // AddOrgMember adds a user to an org with the given role. -func (db *DB) AddOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, user UserRef, role string) error { +func (db *DB) AddOrgMember(ctx context.Context, actor Actor, org OrgRef, user UserRef, role string) error { tx, err := db.beginAs(ctx, actor) if err != nil { return err @@ -375,7 +375,7 @@ func (db *DB) AddOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, use } // UpdateOrgMemberRole changes a member's role. Fails if demoting the last owner. -func (db *DB) UpdateOrgMemberRole(ctx context.Context, actor uuid.UUID, org OrgRef, user UserRef, newRole string) error { +func (db *DB) UpdateOrgMemberRole(ctx context.Context, actor Actor, org OrgRef, user UserRef, newRole string) error { tx, err := db.beginAs(ctx, actor) if err != nil { return err @@ -426,7 +426,7 @@ func (db *DB) UpdateOrgMemberRole(ctx context.Context, actor uuid.UUID, org OrgR } // RemoveOrgMember removes a user from an org. Fails if they are the last owner. -func (db *DB) RemoveOrgMember(ctx context.Context, actor uuid.UUID, org OrgRef, user UserRef) error { +func (db *DB) RemoveOrgMember(ctx context.Context, actor Actor, org OrgRef, user UserRef) error { tx, err := db.beginAs(ctx, actor) if err != nil { return err diff --git a/internal/database/user.go b/internal/database/user.go index 972337d..a6d250c 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -21,15 +21,22 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "golang.org/x/crypto/argon2" + + "dimidiumlabs/mirum/internal/config" ) var ( - ErrSoleOwner = errors.New("database: sole owner of an organization") - ErrEmailTaken = errors.New("database: email already taken") - ErrInvalidCreds = errors.New("database: invalid credentials") - ErrUserNotFound = errors.New("database: user not found") + ErrSoleOwner = errors.New("database: sole owner of an organization") + ErrEmailTaken = errors.New("database: email already taken") + ErrInvalidCreds = errors.New("database: invalid credentials") + ErrUserNotFound = errors.New("database: user not found") + ErrReservedEmail = errors.New("database: email uses a reserved domain") ) +// reservedEmailSuffix is the domain carved out for synthetic actors +// (system/operator/anon). Real users cannot register with this suffix. +const reservedEmailSuffix = "@mirum.local" + // UserRef identifies a user by ID or email. type UserRef struct { id uuid.UUID @@ -53,8 +60,6 @@ const ( argonMemory = 64 * 1024 // 64 MB argonKeyLen = 32 argonThreads = 2 - - SessionTTL = 14 * 24 * time.Hour // 14 days ) // User holds info about a user. @@ -64,13 +69,6 @@ type User struct { CreatedAt time.Time } -// Session holds info about an authenticated session. -type Session struct { - UserID uuid.UUID - Email string - Superuser bool -} - // hashToken returns the hex-encoded SHA-256 of a session token. func hashToken(token string) string { h := sha256.Sum256([]byte(token)) @@ -135,7 +133,11 @@ func hashPassword(password string, pepper []byte) (string, error) { // UserCreate hashes the password with argon2id and inserts a new user. // The pepper is a server-side secret not stored in the database. -func (db *DB) UserCreate(ctx context.Context, actor uuid.UUID, email, password string, pepper []byte) (uuid.UUID, error) { +func (db *DB) UserCreate(ctx context.Context, actor Actor, email, password string, pepper []byte) (uuid.UUID, error) { + if strings.HasSuffix(strings.ToLower(email), reservedEmailSuffix) { + return uuid.Nil, ErrReservedEmail + } + hash, err := hashPassword(password, pepper) if err != nil { return uuid.Nil, err @@ -163,7 +165,7 @@ func (db *DB) UserCreate(ctx context.Context, actor uuid.UUID, email, password s } // GetUser returns a user by ref (ID or email). -func (db *DB) GetUser(ctx context.Context, actor uuid.UUID, ref UserRef) (*User, error) { +func (db *DB) GetUser(ctx context.Context, actor Actor, ref UserRef) (*User, error) { col, val := ref.where() tx, err := db.beginAs(ctx, actor) @@ -190,7 +192,7 @@ func (db *DB) GetUser(ctx context.Context, actor uuid.UUID, ref UserRef) (*User, } // ListUsers returns a page of users and the total count. -func (db *DB) ListUsers(ctx context.Context, actor uuid.UUID, cursor uuid.UUID, limit int, filter string) ([]User, int, error) { +func (db *DB) ListUsers(ctx context.Context, actor Actor, cursor uuid.UUID, limit int, filter string) ([]User, int, error) { if filter != "" { return nil, 0, ErrFilterNotImplemented } @@ -281,10 +283,13 @@ func resolveUser(ctx context.Context, tx pgx.Tx, ref UserRef) (uuid.UUID, error) // UserUpdate updates a user's email and/or password. // Invalidates all sessions when password changes. -func (db *DB) UserUpdate(ctx context.Context, actor uuid.UUID, ref UserRef, email *string, password *string, pepper []byte) error { +func (db *DB) UserUpdate(ctx context.Context, actor Actor, ref UserRef, email *string, password *string, pepper []byte) error { if email == nil && password == nil { return nil } + if email != nil && strings.HasSuffix(strings.ToLower(*email), reservedEmailSuffix) { + return ErrReservedEmail + } tx, err := db.beginAs(ctx, actor) if err != nil { @@ -335,7 +340,7 @@ func (db *DB) UserUpdate(ctx context.Context, actor uuid.UUID, ref UserRef, emai // UserDelete soft-deletes a user. // Fails if the user is the sole owner of any organization. -func (db *DB) UserDelete(ctx context.Context, actor uuid.UUID, ref UserRef) error { +func (db *DB) UserDelete(ctx context.Context, actor Actor, ref UserRef) error { tx, err := db.beginAs(ctx, actor) if err != nil { return err @@ -369,7 +374,7 @@ func (db *DB) UserDelete(ctx context.Context, actor uuid.UUID, ref UserRef) erro } // UserVerifyPassword checks credentials and returns the user ID. -func (db *DB) UserVerifyPassword(ctx context.Context, actor uuid.UUID, email, password string, pepper []byte) (uuid.UUID, error) { +func (db *DB) UserVerifyPassword(ctx context.Context, actor Actor, email, password string, pepper []byte) (uuid.UUID, error) { tx, err := db.beginAs(ctx, actor) if err != nil { return uuid.Nil, err @@ -393,41 +398,49 @@ func (db *DB) UserVerifyPassword(ctx context.Context, actor uuid.UUID, email, pa return id, nil } -// UserGetSession returns session info for a valid, non-expired session. -func (db *DB) UserGetSession(ctx context.Context, actor uuid.UUID, token string) (*Session, error) { +// UserGetSession resolves a session token into the Actor it authenticates. +// Returns an invalid zero Actor on any error; callers must check err. +func (db *DB) UserGetSession(ctx context.Context, actor Actor, token string) (Actor, error) { tx, err := db.beginAs(ctx, actor) if err != nil { - return nil, err + return Actor{}, err } defer tx.Rollback(ctx) - var s Session + var ( + userID uuid.UUID + email string + superuser bool + expiresAt time.Time + ) h := hashToken(token) - var expiresAt time.Time if err := tx.QueryRow(ctx, `SELECT s.user_id, u.email, u.superuser, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > now() AND u.deleted_at IS NULL`, h, - ).Scan(&s.UserID, &s.Email, &s.Superuser, &expiresAt); err != nil { - return nil, err + ).Scan(&userID, &email, &superuser, &expiresAt); err != nil { + return Actor{}, err } - if time.Until(expiresAt) < SessionTTL/2 { + if time.Until(expiresAt) < config.SessionTTL/2 { if _, err := tx.Exec(ctx, `UPDATE sessions SET expires_at = now() + $2 WHERE token = $1`, - h, SessionTTL, + h, config.SessionTTL, ); err != nil { - return nil, err + return Actor{}, err } } - return &s, tx.Commit(ctx) + if err := tx.Commit(ctx); err != nil { + return Actor{}, err + } + return UserActor(userID, email, superuser), nil } // UserCreateSession generates a random token, stores its hash, and returns the token. -func (db *DB) UserCreateSession(ctx context.Context, actor uuid.UUID, userID uuid.UUID) (string, error) { +func (db *DB) UserCreateSession(ctx context.Context, actor Actor, userID uuid.UUID) (string, error) { tx, err := db.beginAs(ctx, actor) if err != nil { return "", err @@ -442,7 +455,7 @@ func (db *DB) UserCreateSession(ctx context.Context, actor uuid.UUID, userID uui if _, err := tx.Exec(ctx, `INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, now() + $3)`, - hashToken(token), userID, SessionTTL, + hashToken(token), userID, config.SessionTTL, ); err != nil { return "", err } @@ -451,7 +464,7 @@ func (db *DB) UserCreateSession(ctx context.Context, actor uuid.UUID, userID uui } // UserDeleteSession removes a session (logout). -func (db *DB) UserDeleteSession(ctx context.Context, actor uuid.UUID, token string) error { +func (db *DB) UserDeleteSession(ctx context.Context, actor Actor, token string) error { tx, err := db.beginAs(ctx, actor) if err != nil { return err @@ -464,9 +477,9 @@ func (db *DB) UserDeleteSession(ctx context.Context, actor uuid.UUID, token stri return tx.Commit(ctx) } -// PurgeExpiredSessions deletes all expired sessions. Runs as root. +// PurgeExpiredSessions deletes all expired sessions. Runs as SystemActor. func (db *DB) PurgeExpiredSessions(ctx context.Context) error { - tx, err := db.beginAs(ctx, uuid.Nil) + tx, err := db.beginAs(ctx, SystemActor()) if err != nil { return err } diff --git a/internal/database/worker.go b/internal/database/worker.go index a92e6e6..3680901 100644 --- a/internal/database/worker.go +++ b/internal/database/worker.go @@ -26,7 +26,7 @@ type Worker struct { } // GetWorker returns a worker by ID. -func (db *DB) GetWorker(ctx context.Context, actor uuid.UUID, id uuid.UUID) (*Worker, error) { +func (db *DB) GetWorker(ctx context.Context, actor Actor, id uuid.UUID) (*Worker, error) { tx, err := db.beginAs(ctx, actor) if err != nil { return nil, err @@ -48,7 +48,7 @@ func (db *DB) GetWorker(ctx context.Context, actor uuid.UUID, id uuid.UUID) (*Wo } // CreateWorker registers a new worker with the given public key and optional org. -func (db *DB) CreateWorker(ctx context.Context, actor uuid.UUID, publicKey []byte, org *OrgRef) (uuid.UUID, error) { +func (db *DB) CreateWorker(ctx context.Context, actor Actor, publicKey []byte, org *OrgRef) (uuid.UUID, error) { tx, err := db.beginAs(ctx, actor) if err != nil { return uuid.Nil, err @@ -76,7 +76,7 @@ func (db *DB) CreateWorker(ctx context.Context, actor uuid.UUID, publicKey []byt } // DeleteWorker soft-deletes a worker by ID. -func (db *DB) DeleteWorker(ctx context.Context, actor uuid.UUID, id uuid.UUID) error { +func (db *DB) DeleteWorker(ctx context.Context, actor Actor, id uuid.UUID) error { tx, err := db.beginAs(ctx, actor) if err != nil { return err @@ -97,7 +97,7 @@ func (db *DB) DeleteWorker(ctx context.Context, actor uuid.UUID, id uuid.UUID) e } // ListWorkers returns a page of workers and the total count. -func (db *DB) ListWorkers(ctx context.Context, actor uuid.UUID, cursor uuid.UUID, limit int, filter string) ([]Worker, int, error) { +func (db *DB) ListWorkers(ctx context.Context, actor Actor, cursor uuid.UUID, limit int, filter string) ([]Worker, int, error) { if filter != "" { return nil, 0, ErrFilterNotImplemented } @@ -144,7 +144,7 @@ func (db *DB) ListWorkers(ctx context.Context, actor uuid.UUID, cursor uuid.UUID } // LookupWorker finds an active worker by its ed25519 public key. -func (db *DB) LookupWorker(ctx context.Context, actor uuid.UUID, publicKey []byte) (*Worker, error) { +func (db *DB) LookupWorker(ctx context.Context, actor Actor, publicKey []byte) (*Worker, error) { tx, err := db.beginAs(ctx, actor) if err != nil { return nil, err diff --git a/internal/protocol/backoff.go b/internal/protocol/backoff.go index dc53e60..5a3cf20 100644 --- a/internal/protocol/backoff.go +++ b/internal/protocol/backoff.go @@ -7,6 +7,8 @@ import ( "context" "math/rand/v2" "time" + + "dimidiumlabs/mirum/internal/config" ) type Backoff struct { @@ -15,7 +17,7 @@ type Backoff struct { } func NewBackoff() *Backoff { - return &Backoff{Min: time.Second, Max: 60 * time.Second} + return &Backoff{Min: config.WorkerBackoffMin, Max: config.WorkerBackoffMax} } func (b *Backoff) Reset() { diff --git a/internal/protocol/handshake.go b/internal/protocol/handshake.go index 5d020a2..204600a 100644 --- a/internal/protocol/handshake.go +++ b/internal/protocol/handshake.go @@ -15,6 +15,8 @@ import ( "net/url" "os" "time" + + "dimidiumlabs/mirum/internal/config" ) var ( @@ -108,7 +110,7 @@ func SelfSignedCert(key ed25519.PrivateKey, meta *WorkerMeta) (tls.Certificate, tmpl := &x509.Certificate{ SerialNumber: serial, NotBefore: now, - NotAfter: now.Add(24 * time.Hour), + NotAfter: now.Add(config.WorkerCertLifetime), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, URIs: []*url.URL{meta.URI()}, diff --git a/proto/admin.proto b/proto/admin.proto index 7f2231a..58f4e64 100644 --- a/proto/admin.proto +++ b/proto/admin.proto @@ -61,6 +61,53 @@ enum Perm { PERM_WORKER_WRITE = 10; } +// ErrorInfo is attached as a ConnectError detail on every error. +// The ConnectError message field is always empty — the server never sends +// human-readable text. Clients switch on reason to select user-facing copy. +message ErrorInfo { + ErrorReason reason = 1; + + // Short stable identifiers for UI targeting (e.g. {"field": "slug"}). + // Never human-readable text. + map metadata = 2; +} + +enum ErrorReason { + ERROR_REASON_UNSPECIFIED = 0; + ERROR_REASON_INTERNAL = 1; + + // Lookup failures + ERROR_REASON_USER_NOT_FOUND = 10; + ERROR_REASON_ORG_NOT_FOUND = 11; + ERROR_REASON_WORKER_NOT_FOUND = 12; + ERROR_REASON_MEMBER_NOT_FOUND = 13; + + // Conflicts + ERROR_REASON_EMAIL_TAKEN = 20; + ERROR_REASON_SLUG_TAKEN = 21; + ERROR_REASON_ALREADY_MEMBER = 22; + + // State preconditions + ERROR_REASON_LAST_OWNER = 30; + ERROR_REASON_SOLE_OWNER = 31; + + // Validation + ERROR_REASON_INVALID_SLUG = 40; + ERROR_REASON_INVALID_ROLE = 41; + ERROR_REASON_RESERVED_EMAIL = 42; + + // Auth + ERROR_REASON_UNAUTHENTICATED = 50; + ERROR_REASON_PERMISSION_DENIED = 51; + ERROR_REASON_INVALID_CREDENTIALS = 52; + ERROR_REASON_INVALID_CSRF = 53; + + // Infrastructure + ERROR_REASON_RATE_LIMITED = 60; + ERROR_REASON_UNAVAILABLE = 61; + ERROR_REASON_UNIMPLEMENTED = 62; +} + message UserRef { oneof ref { option (buf.validate.oneof).required = true; -- Gilti