first commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"nadir"
|
||||
"nadir/internal/auth"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
type HealthOutput struct {
|
||||
Body struct {
|
||||
Status string `json:"status" example:"ok" doc:"Overall health"`
|
||||
Database string `json:"database" example:"ok" doc:"Embedded SQLite session store state"`
|
||||
Version string `json:"version" example:"1.0.0" doc:"Application version"`
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHealth adds a public liveness/readiness probe. It is intentionally
|
||||
// unauthenticated (no permission metadata) so load balancers and orchestrators
|
||||
// can reach it. Returns 503 when the SQLite session store is unreachable, so
|
||||
// probes can key off the status code without parsing the body.
|
||||
func RegisterHealth(api huma.API, sessions *auth.SessionStore) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "health",
|
||||
Method: "GET",
|
||||
Path: "/api/health",
|
||||
Summary: "Health check",
|
||||
Description: "Public liveness/readiness probe. Reports whether the embedded " +
|
||||
"SQLite session store is reachable. Returns 503 when it is not.",
|
||||
Tags: []string{"Meta"},
|
||||
Errors: []int{503},
|
||||
}, func(ctx context.Context, _ *struct{}) (*HealthOutput, error) {
|
||||
if err := sessions.Ping(); err != nil {
|
||||
return nil, huma.Error503ServiceUnavailable("session database unreachable", err)
|
||||
}
|
||||
out := &HealthOutput{}
|
||||
out.Body.Status = "ok"
|
||||
out.Body.Database = "ok"
|
||||
out.Body.Version = nadir.Version
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"nadir/internal/module"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// ModuleInfo describes a registered module: its stable ID, display name, and
|
||||
// the permissions it exposes. The frontend uses this to drive navigation and
|
||||
// render the role/permission matrix.
|
||||
type ModuleInfo struct {
|
||||
ID string `json:"id" example:"system" doc:"Stable module identifier"`
|
||||
Name string `json:"name" example:"System" doc:"Human-readable module name"`
|
||||
Permissions []string `json:"permissions" doc:"Permissions this module exposes (never includes the \"*\" wildcard)"`
|
||||
}
|
||||
|
||||
type ModulesOutput struct {
|
||||
Body struct {
|
||||
Modules []ModuleInfo `json:"modules" doc:"Registered modules, sorted by ID"`
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds the read-only module-discovery endpoint. It is intentionally
|
||||
// public: it exposes only the API's static shape (module IDs and permission
|
||||
// vocabulary), the same information already served by /openapi.json. The module
|
||||
// list is fixed at startup, so the response is computed once here.
|
||||
func Register(api huma.API, mods []module.Module) {
|
||||
infos := make([]ModuleInfo, 0, len(mods))
|
||||
for _, m := range mods {
|
||||
perms := m.Permissions()
|
||||
ps := make([]string, len(perms))
|
||||
for i, p := range perms {
|
||||
ps[i] = string(p)
|
||||
}
|
||||
infos = append(infos, ModuleInfo{ID: m.ID(), Name: m.Name(), Permissions: ps})
|
||||
}
|
||||
slices.SortFunc(infos, func(a, b ModuleInfo) int { return cmp.Compare(a.ID, b.ID) })
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "list-modules",
|
||||
Method: "GET",
|
||||
Path: "/api/_modules",
|
||||
Summary: "List registered modules",
|
||||
Description: "Returns every registered module with its ID, display name, " +
|
||||
"and exported permissions. Public (same static shape as /openapi.json); " +
|
||||
"used by the frontend for navigation and the role/permission matrix.",
|
||||
Tags: []string{"Meta"},
|
||||
}, func(ctx context.Context, _ *struct{}) (*ModulesOutput, error) {
|
||||
out := &ModulesOutput{}
|
||||
out.Body.Modules = infos
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"nadir/internal/auth"
|
||||
"nadir/internal/module"
|
||||
"nadir/internal/rbac"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// WhoamiInput carries the session cookie. The endpoint is not behind the RBAC
|
||||
// middleware (it requires no specific permission), so it validates the session
|
||||
// itself.
|
||||
type WhoamiInput struct {
|
||||
SessionID string `cookie:"nadir_session_id"`
|
||||
}
|
||||
|
||||
// WhoamiBody reports who the caller is and, per module, which permissions they
|
||||
// actually hold. Combined with /api/_modules (the full module/permission grid),
|
||||
// this gives the frontend everything it needs to render the permission matrix.
|
||||
type WhoamiBody struct {
|
||||
Username string `json:"username" example:"urania" doc:"Authenticated username"`
|
||||
Permissions map[string][]string `json:"permissions" doc:"Module ID -> permissions the caller holds. Modules where they hold none are omitted."`
|
||||
}
|
||||
|
||||
type WhoamiOutput struct{ Body WhoamiBody }
|
||||
|
||||
// RegisterWhoami adds the current-user endpoint. It resolves the caller's
|
||||
// concrete grants by asking the RBAC store about each module's permissions,
|
||||
// so "*" wildcards in roles are expanded for free.
|
||||
func RegisterWhoami(api huma.API, sessions *auth.SessionStore, roles *rbac.RBAC, mods []module.Module) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "whoami",
|
||||
Method: "GET",
|
||||
Path: "/api/whoami",
|
||||
Summary: "Get the current user and their permissions",
|
||||
Description: "Returns the authenticated username and, per module, the " +
|
||||
"permissions the caller holds (wildcards resolved). Pair with " +
|
||||
"/api/_modules to render the full permission matrix.",
|
||||
Tags: []string{"Meta"},
|
||||
Errors: []int{401},
|
||||
}, func(ctx context.Context, in *WhoamiInput) (*WhoamiOutput, error) {
|
||||
sess, ok := sessions.GetByToken(in.SessionID)
|
||||
if !ok {
|
||||
return nil, huma.Error401Unauthorized("unauthorized")
|
||||
}
|
||||
|
||||
held := make(map[string][]string)
|
||||
for _, m := range mods {
|
||||
var perms []string
|
||||
for _, p := range m.Permissions() {
|
||||
if roles.Can(sess.Username, m.ID(), p) {
|
||||
perms = append(perms, string(p))
|
||||
}
|
||||
}
|
||||
if len(perms) > 0 {
|
||||
held[m.ID()] = perms
|
||||
}
|
||||
}
|
||||
|
||||
out := &WhoamiOutput{}
|
||||
out.Body = WhoamiBody{Username: sess.Username, Permissions: held}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user