mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
Let Hanzo IAM answer who the caller is
o11y carried a second identity system: email/password sign-in, Google,
OIDC and SAML callbacks, self-registration, invites, reset-password
tokens, roles-on-users, SSO auth domains, and a pair of tokenizers that
minted and stored its own sessions. Hanzo IAM answers all of it, once,
at the edge. Two credential stores is one too many, and the one nobody
signs into is the one that rots.
What is left is Identity: WHO a request is, resolved from what the edge
asserted or from a service-account key, never issued here. The routes are
not merely unrouted — the handlers, stores, tables and templates behind
them are gone, and anonymous_test.go names each dead address so that
re-exempting one fails even though no route can come back.
The census moves with it: 366 addresses to 329, 353 typed ops to 319, ten
escape hatches to seven. zipdoc_gen.go loses the 34 descriptions whose
addresses left, because zip keys its doc registry by method and path, so
a description outliving its declaration attaches this module's prose to
whatever a host serves at that address. zipdoc_test.go pins that.
Cherry-picked from delete/o11y-identity 89854a68ae.
This commit is contained in:
@@ -606,3 +606,43 @@ type O11yTransactionResult struct {
|
||||
}
|
||||
|
||||
// ── the seam ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── role records ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// These lived in identity.go while the identity face still administered members
|
||||
// and the roles they held. That face is gone — Hanzo IAM administers members —
|
||||
// but ROLES themselves are not identity: they are o11y's own vocabulary for what
|
||||
// may be done to o11y's own dashboards, alerts and views, which is this file's
|
||||
// subject. So the records moved to the face that uses them.
|
||||
|
||||
// O11yRolesOut is a list of roles.
|
||||
type O11yRolesOut struct {
|
||||
// Status is "success".
|
||||
Status string `json:"status"`
|
||||
// Data holds the roles.
|
||||
Data []O11yRole `json:"data"`
|
||||
}
|
||||
|
||||
// O11yRole is one role.
|
||||
type O11yRole struct {
|
||||
// ID is the role id.
|
||||
ID string `json:"id"`
|
||||
// CreatedAt is when the role was created.
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
// UpdatedAt is when it last changed.
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// Name is the role's name.
|
||||
Name string `json:"name"`
|
||||
// Description says what the role is for.
|
||||
Description string `json:"description"`
|
||||
// Type is how the role came to be — managed by the platform or custom.
|
||||
Type string `json:"type"`
|
||||
// OrgID is the org the role belongs to.
|
||||
OrgID string `json:"orgId"`
|
||||
}
|
||||
|
||||
// O11yCreated is a newly created record, named by id.
|
||||
type O11yCreated struct {
|
||||
// ID is the new record's id.
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
+16
-26
@@ -71,37 +71,27 @@ var openOps = map[string]bool{
|
||||
"GET /v1/o11y/version": true,
|
||||
"GET /v1/o11y/health": true,
|
||||
|
||||
// The console's pre-login configuration — branding, the ingest URL, which
|
||||
// sign-in methods this deployment offers. Read to RENDER the sign-in page,
|
||||
// so requiring a session to read it is circular.
|
||||
// The console's pre-session configuration — branding and the ingest URL.
|
||||
// Read before the console knows whether it has a session, so requiring one
|
||||
// to read it is circular.
|
||||
"GET /v1/o11y/global/config": true,
|
||||
|
||||
// THE PATH TO HAVING A PRINCIPAL. Requiring one to obtain one is the same
|
||||
// circle, and the failure it causes is total: sign-in itself stops working.
|
||||
// Registration is here because on a fresh deployment there is no user yet to
|
||||
// hold a principal; the runtime refuses it once setup has completed.
|
||||
"POST /v1/o11y/register": true,
|
||||
"POST /v1/o11y/sessions/email_password": true,
|
||||
"GET /v1/o11y/sessions/context": true,
|
||||
"POST /v1/o11y/sessions/rotate": true,
|
||||
"DELETE /v1/o11y/sessions": true,
|
||||
"GET /v1/o11y/complete/google": true,
|
||||
"GET /v1/o11y/complete/oidc": true,
|
||||
"POST /v1/o11y/complete/saml": true,
|
||||
"POST /v1/o11y/reset_password_tokens/verify": true,
|
||||
"POST /v1/o11y/resetPassword": true,
|
||||
"POST /v1/o11y/factor_password/forgot": true,
|
||||
|
||||
// SELF-ADDRESSED: the subject is the caller's own credential, never a tenant
|
||||
// THE PATH TO HAVING A PRINCIPAL used to be eleven entries long — sign-in,
|
||||
// registration, session rotation, sign-out, three SSO callbacks and three
|
||||
// password-reset routes — because o11y authenticated people itself. It
|
||||
// authenticates nobody now: Hanzo IAM does, at hanzo.id, and the edge hands
|
||||
// this process an already-validated identity. There is no unauthenticated
|
||||
// path to a principal here because there is no principal minted here, and
|
||||
// the entries are DELETED rather than left standing, since an exemption is
|
||||
// only ever a hole waiting for a route to be re-added under it.
|
||||
//
|
||||
// SELF-ADDRESSED: the subject is the caller's own identity, never a tenant
|
||||
// named in the request. There is no X-Org-Id to forge into these — "me" is
|
||||
// read from the claims the runtime resolved — so the host has nothing to
|
||||
// protect and a caller with no claims gets the runtime's own 401.
|
||||
"GET /v1/o11y/user/me": true,
|
||||
"GET /v1/o11y/users/me": true,
|
||||
"PUT /v1/o11y/users/me": true,
|
||||
"PUT /v1/o11y/users/me/factor_password": true,
|
||||
"GET /v1/o11y/service_accounts/me": true,
|
||||
"PUT /v1/o11y/service_accounts/me": true,
|
||||
"GET /v1/o11y/users/me": true,
|
||||
"GET /v1/o11y/service_accounts/me": true,
|
||||
"PUT /v1/o11y/service_accounts/me": true,
|
||||
}
|
||||
|
||||
// publicDashboardRead reports whether path is one of the two reads a SHARED
|
||||
|
||||
+26
-6
@@ -19,6 +19,18 @@ var fixedExemptions = []string{
|
||||
"GET /v1/o11y/version",
|
||||
"GET /v1/o11y/health",
|
||||
"GET /v1/o11y/global/config",
|
||||
"GET /v1/o11y/users/me",
|
||||
"GET /v1/o11y/service_accounts/me",
|
||||
"PUT /v1/o11y/service_accounts/me",
|
||||
}
|
||||
|
||||
// noCredentialSurfaceIsExempt is the list this file used to be half made of:
|
||||
// registration, sign-in, session rotation, sign-out, three SSO callbacks and
|
||||
// three password routes. o11y authenticates nobody now, so none of them is a
|
||||
// route and none may be an exemption. Naming them keeps the deletion honest —
|
||||
// re-adding any one of them to openOps fails here even if the route never comes
|
||||
// back, because an exemption without a route is the hole that outlives the door.
|
||||
var noCredentialSurfaceIsExempt = []string{
|
||||
"POST /v1/o11y/register",
|
||||
"POST /v1/o11y/sessions/email_password",
|
||||
"GET /v1/o11y/sessions/context",
|
||||
@@ -30,12 +42,20 @@ var fixedExemptions = []string{
|
||||
"POST /v1/o11y/reset_password_tokens/verify",
|
||||
"POST /v1/o11y/resetPassword",
|
||||
"POST /v1/o11y/factor_password/forgot",
|
||||
"GET /v1/o11y/user/me",
|
||||
"GET /v1/o11y/users/me",
|
||||
"PUT /v1/o11y/users/me",
|
||||
"PUT /v1/o11y/users/me/factor_password",
|
||||
"GET /v1/o11y/service_accounts/me",
|
||||
"PUT /v1/o11y/service_accounts/me",
|
||||
}
|
||||
|
||||
func TestNoCredentialSurfaceIsExemptOrRouted(t *testing.T) {
|
||||
have := registered(t, mounted(t))
|
||||
for _, route := range noCredentialSurfaceIsExempt {
|
||||
method, path, _ := strings.Cut(route, " ")
|
||||
if o11y.Anonymous(method, path) {
|
||||
t.Errorf("%q is exempt from authentication and must not be: o11y holds no credentials", route)
|
||||
}
|
||||
if have[route] {
|
||||
t.Errorf("%q is still registered — Hanzo IAM owns credentials", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE DRIFT GUARD. The unified binary refused every public o11y op — /version,
|
||||
@@ -93,7 +113,7 @@ func TestPublicOpsAreExempt(t *testing.T) {
|
||||
"GET /v1/o11y/healthz",
|
||||
"GET /v1/o11y/readyz",
|
||||
"GET /v1/o11y/global/config",
|
||||
"POST /v1/o11y/sessions/email_password",
|
||||
"GET /v1/o11y/users/me",
|
||||
"GET /v1/o11y/public/dashboards/d1",
|
||||
"GET /v1/o11y/public/dashboards/d1/widgets/0/query_range",
|
||||
"POST /v1/event/proj/envelope/",
|
||||
|
||||
+72
-953
File diff suppressed because it is too large
Load Diff
+116
-192
@@ -13,57 +13,27 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// THE WIRE PROOF for the identity face — telemetry_test.go's discipline applied
|
||||
// to the forty-five typed identity ops. The reads take the bytes the RUNTIME
|
||||
// wrote (through the SAME render.Success the handlers use) and the bytes the OP
|
||||
// answered and demand they are the same bytes, for a payload built from the
|
||||
// face's OWN types with every field populated. The writes prove the body the
|
||||
// runtime receives is the caller's own, field for field. A field this port
|
||||
// failed to name, or named with a different tag, or ordered differently, shows
|
||||
// up here as a diff. The routing and document proofs pin the whole face at once.
|
||||
// THE WIRE PROOF for what is LEFT of the identity face. The reads take the bytes
|
||||
// the RUNTIME wrote (through the SAME render.Success the handlers use) and the
|
||||
// bytes the OP answered and demand they are the same bytes, for a payload built
|
||||
// from the face's OWN types with every field populated. A field this port failed
|
||||
// to name, or named with a different tag, or ordered differently, shows up here
|
||||
// as a diff.
|
||||
//
|
||||
// It also proves the DELETIONS, which is the harder half: thirty-three ops and
|
||||
// three redirect hatches are gone, and a deletion that leaves the door standing
|
||||
// is not a deletion. TestDeletedIdentityRoutesAre404 asks the router for each of
|
||||
// them by name.
|
||||
//
|
||||
// The helpers (mounted, runtime, call, member, mustJSON) are telemetry_test.go's;
|
||||
// every typed face is proved with the one harness.
|
||||
|
||||
// identityOps is the face's routing table, spelled once: the forty-five typed
|
||||
// ops, their methods and their operation ids, as mountIdentity registers them.
|
||||
// The routing proof reads it as native Fiber routes, the document proof as
|
||||
// OpenAPI paths — one source, two projections.
|
||||
// identityOps is the face's routing table, spelled once: the twelve typed ops,
|
||||
// their methods and their operation ids, as mountIdentity registers them. The
|
||||
// routing proof reads it as native Fiber routes, the document proof as OpenAPI
|
||||
// paths — one source, two projections.
|
||||
var identityOps = []struct{ Method, Path, OpID string }{
|
||||
{"POST", "/invite", "CreateInvite"},
|
||||
{"POST", "/invite/bulk", "CreateBulkInvite"},
|
||||
{"GET", "/user", "ListUsersDeprecated"},
|
||||
{"GET", "/users", "ListUsers"},
|
||||
{"GET", "/user/me", "GetMyUserDeprecated"},
|
||||
{"GET", "/users/me", "GetMyUser"},
|
||||
{"POST", "/users", "CreateUser"},
|
||||
{"PUT", "/users/me", "UpdateMyUserV2"},
|
||||
{"GET", "/user/:id", "GetUserDeprecated"},
|
||||
{"GET", "/users/:id", "GetUser"},
|
||||
{"PUT", "/user/:id", "UpdateUserDeprecated"},
|
||||
{"PUT", "/users/:id", "UpdateUser"},
|
||||
{"DELETE", "/user/:id", "DeleteUserDeprecated"},
|
||||
{"DELETE", "/users/:id", "DeleteUser"},
|
||||
{"GET", "/getResetPasswordToken/:id", "GetResetPasswordTokenDeprecated"},
|
||||
{"GET", "/users/:id/reset_password_tokens", "GetResetPasswordToken"},
|
||||
{"PUT", "/users/:id/reset_password_tokens", "CreateResetPasswordToken"},
|
||||
{"POST", "/reset_password_tokens/verify", "VerifyResetPasswordToken"},
|
||||
{"POST", "/resetPassword", "ResetPassword"},
|
||||
{"PUT", "/users/me/factor_password", "UpdateMyPassword"},
|
||||
{"POST", "/factor_password/forgot", "ForgotPassword"},
|
||||
{"GET", "/users/:id/roles", "GetRolesByUserID"},
|
||||
{"POST", "/users/:id/roles", "SetRoleByUserID"},
|
||||
{"DELETE", "/users/:id/roles/:roleId", "RemoveUserRoleByUserIDAndRoleID"},
|
||||
{"GET", "/roles/:id/users", "GetUsersByRoleID"},
|
||||
{"POST", "/sessions/email_password", "CreateSessionByEmailPassword"},
|
||||
{"GET", "/sessions/context", "GetSessionContext"},
|
||||
{"POST", "/sessions/rotate", "RotateSession"},
|
||||
{"DELETE", "/sessions", "DeleteSession"},
|
||||
{"GET", "/domains", "ListAuthDomains"},
|
||||
{"POST", "/domains", "CreateAuthDomain"},
|
||||
{"GET", "/domains/:id", "GetAuthDomain"},
|
||||
{"PUT", "/domains/:id", "UpdateAuthDomain"},
|
||||
{"DELETE", "/domains/:id", "DeleteAuthDomain"},
|
||||
{"GET", "/orgs/me", "GetMyOrganization"},
|
||||
{"PUT", "/orgs/me", "UpdateMyOrganization"},
|
||||
{"GET", "/orgs/me/filters", "GetQuickFilters"},
|
||||
@@ -77,6 +47,59 @@ var identityOps = []struct{ Method, Path, OpID string }{
|
||||
{"PUT", "/org/preferences/:name", "UpdateOrgPreference"},
|
||||
}
|
||||
|
||||
// deletedIdentityRoutes is every method+path the identity face used to answer
|
||||
// and must not answer any more — the credential surfaces (a), the member
|
||||
// administration (c), and the three sign-in callbacks that were the last
|
||||
// authentication hatches. Spelled out rather than counted, because the failure
|
||||
// this guards against is ONE of them surviving.
|
||||
var deletedIdentityRoutes = []struct{ Method, Path string }{
|
||||
// invites
|
||||
{"POST", "/v1/o11y/invite"},
|
||||
{"POST", "/v1/o11y/invite/bulk"},
|
||||
// member administration
|
||||
{"GET", "/v1/o11y/user"},
|
||||
{"GET", "/v1/o11y/users"},
|
||||
{"POST", "/v1/o11y/users"},
|
||||
{"GET", "/v1/o11y/user/me"},
|
||||
{"PUT", "/v1/o11y/users/me"},
|
||||
{"GET", "/v1/o11y/user/u1"},
|
||||
{"GET", "/v1/o11y/users/u1"},
|
||||
{"PUT", "/v1/o11y/user/u1"},
|
||||
{"PUT", "/v1/o11y/users/u1"},
|
||||
{"DELETE", "/v1/o11y/user/u1"},
|
||||
{"DELETE", "/v1/o11y/users/u1"},
|
||||
// passwords and their reset tokens
|
||||
{"GET", "/v1/o11y/getResetPasswordToken/u1"},
|
||||
{"GET", "/v1/o11y/users/u1/reset_password_tokens"},
|
||||
{"PUT", "/v1/o11y/users/u1/reset_password_tokens"},
|
||||
{"POST", "/v1/o11y/reset_password_tokens/verify"},
|
||||
{"POST", "/v1/o11y/resetPassword"},
|
||||
{"PUT", "/v1/o11y/users/me/factor_password"},
|
||||
{"POST", "/v1/o11y/factor_password/forgot"},
|
||||
// roles on users
|
||||
{"GET", "/v1/o11y/users/u1/roles"},
|
||||
{"POST", "/v1/o11y/users/u1/roles"},
|
||||
{"DELETE", "/v1/o11y/users/u1/roles/r1"},
|
||||
{"GET", "/v1/o11y/roles/r1/users"},
|
||||
// sessions
|
||||
{"POST", "/v1/o11y/sessions/email_password"},
|
||||
{"GET", "/v1/o11y/sessions/context"},
|
||||
{"POST", "/v1/o11y/sessions/rotate"},
|
||||
{"DELETE", "/v1/o11y/sessions"},
|
||||
// SSO domains
|
||||
{"GET", "/v1/o11y/domains"},
|
||||
{"POST", "/v1/o11y/domains"},
|
||||
{"GET", "/v1/o11y/domains/d1"},
|
||||
{"PUT", "/v1/o11y/domains/d1"},
|
||||
{"DELETE", "/v1/o11y/domains/d1"},
|
||||
// the sign-in callbacks
|
||||
{"GET", "/v1/o11y/complete/google"},
|
||||
{"POST", "/v1/o11y/complete/saml"},
|
||||
{"GET", "/v1/o11y/complete/oidc"},
|
||||
// self-registration
|
||||
{"POST", "/v1/o11y/register"},
|
||||
}
|
||||
|
||||
// bracePath rewrites a zip route's ":seg" parameters into the OpenAPI document's
|
||||
// "{seg}" form, so identityOps can address both the router and the spec.
|
||||
func bracePath(zipPath string) string {
|
||||
@@ -89,13 +112,10 @@ func bracePath(zipPath string) string {
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
// THE ROUTES, exactly as the mux tree declared them: forty-five typed paths on
|
||||
// the native router, and the three /complete/* sign-in callbacks NOT among them —
|
||||
// they answer with 303 redirects, so they stay on the delegation wildcard where
|
||||
// a typed 2xx-JSON contract cannot lie about them.
|
||||
func TestIdentityRoutesAreTheSameFortyFive(t *testing.T) {
|
||||
if len(identityOps) != 45 {
|
||||
t.Fatalf("identityOps has %d entries, want 45", len(identityOps))
|
||||
// THE ROUTES, exactly as mountIdentity declares them.
|
||||
func TestIdentityRoutesAreTheSameTwelve(t *testing.T) {
|
||||
if len(identityOps) != 12 {
|
||||
t.Fatalf("identityOps has %d entries, want 12", len(identityOps))
|
||||
}
|
||||
app := mounted(t)
|
||||
got := map[string]bool{}
|
||||
@@ -108,104 +128,37 @@ func TestIdentityRoutesAreTheSameFortyFive(t *testing.T) {
|
||||
t.Errorf("%s is not registered as a typed op", key)
|
||||
}
|
||||
}
|
||||
// The three SSO callbacks are NAMED HATCHES now (mount.go mountHatches), not
|
||||
// typed ops and no longer a fall-through: they answer 303 with a Location and
|
||||
// no body, so a typed op would publish a response schema for a response that
|
||||
// does not exist. Each must be registered — a hatch that is not registered is
|
||||
// a 404 now that the catch-all is gone.
|
||||
for _, cb := range []string{
|
||||
"GET /v1/o11y/complete/google",
|
||||
"POST /v1/o11y/complete/saml",
|
||||
"GET /v1/o11y/complete/oidc",
|
||||
} {
|
||||
if !got[cb] {
|
||||
t.Errorf("%s is not registered; it is a named hatch (it answers 303)", cb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The three SSO callbacks reach the runtime through their OWN named routes now,
|
||||
// path untouched, with the 303 and its Location passed straight back — while the
|
||||
// typed identity paths next to them dispatch as ops. Both halves in one test,
|
||||
// because the interesting failure is one of them stealing the other's traffic.
|
||||
// THE DELETION, on the router the binary serves.
|
||||
//
|
||||
// This test used to install a host wildcard and assert the callbacks fell into
|
||||
// it. That assertion could not distinguish a deliberate hatch from a route
|
||||
// nobody had converted, which is how three unmounted slices shipped; it now
|
||||
// asserts the named route instead.
|
||||
func TestCompleteCallbacksAreNamedHatches(t *testing.T) {
|
||||
// A registered runtime answers 200 to EVERYTHING, so a route that still exists
|
||||
// answers 200 here and a route that is gone answers 404. That is the whole
|
||||
// discrimination, and it is why the runtime is registered rather than left nil:
|
||||
// with no runtime every path returns 503, and 503 would hide a surviving door.
|
||||
func TestDeletedIdentityRoutesAre404(t *testing.T) {
|
||||
app := mounted(t)
|
||||
|
||||
var saw string
|
||||
o11y.SetRuntime(o11y.Whole(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
saw = r.URL.Path
|
||||
w.Header().Set("Location", "https://console.hanzo.ai/")
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
})))
|
||||
t.Cleanup(func() { o11y.SetRuntime(nil) })
|
||||
|
||||
for _, cb := range []struct{ method, target string }{
|
||||
{http.MethodGet, "/v1/o11y/complete/google"},
|
||||
{http.MethodPost, "/v1/o11y/complete/saml"},
|
||||
{http.MethodGet, "/v1/o11y/complete/oidc"},
|
||||
} {
|
||||
saw = ""
|
||||
resp, err := app.Test(member(cb.method, cb.target, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Errorf("%s status=%d, want 303 — the redirect is the answer", cb.target, resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "https://console.hanzo.ai/" {
|
||||
t.Errorf("%s Location=%q — the header a typed op would have hidden", cb.target, got)
|
||||
}
|
||||
if saw != cb.target {
|
||||
t.Errorf("%s reached the runtime as %q", cb.target, saw)
|
||||
}
|
||||
}
|
||||
|
||||
// ...and a typed identity op beside it still dispatches as an op.
|
||||
runtime(t, []any{})
|
||||
if status, body := call(t, app, member(http.MethodGet, "/v1/o11y/users", nil)); status != http.StatusOK {
|
||||
t.Fatalf("the typed users op did not answer: status=%d %s", status, body)
|
||||
|
||||
for _, r := range deletedIdentityRoutes {
|
||||
// 404 (no such path) or 405 (the path survives for another method, as
|
||||
// /users/me does for GET) both mean this method+path is not served. A
|
||||
// 2xx, a 4xx from a handler, or the 503 of an unwired hatch would not.
|
||||
status, body := call(t, app, member(r.Method, r.Path, strings.NewReader("{}")))
|
||||
if status != http.StatusNotFound && status != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s %s answers %d (%s) — it must be gone, not merely unrouted", r.Method, r.Path, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A page of org members is what the runtime wrote, to the byte — each member's
|
||||
// every field populated, so a dropped or renamed field cannot hide behind a
|
||||
// zero value — and the runtime is asked at the member collection, verbatim.
|
||||
func TestUsersAnswerIsTheRuntimeAnswer(t *testing.T) {
|
||||
// The caller's own identity is what the runtime wrote, to the byte — every field
|
||||
// populated, so a dropped or renamed field cannot hide behind a zero value.
|
||||
func TestMyUserAnswerIsTheRuntimeAnswer(t *testing.T) {
|
||||
at := time.Date(2026, 7, 31, 12, 0, 0, 123456789, time.UTC)
|
||||
app := mounted(t)
|
||||
wrote, asked := runtime(t, []o11y.O11yUser{
|
||||
{ID: "u1", DisplayName: "Ada", Email: "[email protected]", OrgID: "maxpower", IsRoot: true, Status: "active", CreatedAt: at, UpdatedAt: at.Add(time.Hour)},
|
||||
{ID: "u2", DisplayName: "Grace", Email: "[email protected]", OrgID: "maxpower", Status: "pending_invite", CreatedAt: at, UpdatedAt: at},
|
||||
})
|
||||
|
||||
status, got := call(t, app, member(http.MethodGet, "/v1/o11y/users", nil))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s, want 200", status, got)
|
||||
}
|
||||
if !bytes.Equal(got, *wrote) {
|
||||
t.Fatalf("the op changed the bytes.\n runtime: %s\n op: %s", *wrote, got)
|
||||
}
|
||||
if r := *asked; r.URL.Path != "/v1/o11y/users" {
|
||||
t.Fatalf("runtime was asked %q, want /v1/o11y/users", r.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// The calling user and every role they hold is what the runtime wrote, to the
|
||||
// byte — a nested role assignment carrying its own role, so a member's whole
|
||||
// role graph is proved, not just the scalar fields.
|
||||
func TestUserWithRolesAnswerIsTheRuntimeAnswer(t *testing.T) {
|
||||
at := time.Date(2026, 7, 31, 12, 0, 0, 123456789, time.UTC)
|
||||
app := mounted(t)
|
||||
role := &o11y.O11yRole{ID: "r1", CreatedAt: at, UpdatedAt: at, Name: "ADMIN", Description: "full access", Type: "managed", OrgID: "maxpower"}
|
||||
wrote, asked := runtime(t, o11y.O11yUserWithRoles{
|
||||
ID: "u1", DisplayName: "Ada", Email: "[email protected]", OrgID: "maxpower", IsRoot: true, Status: "active",
|
||||
CreatedAt: at, UpdatedAt: at,
|
||||
UserRoles: []o11y.O11yUserRole{{ID: "ur1", UserID: "u1", RoleID: "r1", CreatedAt: at, UpdatedAt: at, Role: role}},
|
||||
wrote, asked := runtime(t, o11y.O11yUser{
|
||||
ID: "u1", DisplayName: "ada@example.com", Email: "[email protected]", OrgID: "maxpower",
|
||||
IsRoot: false, Status: "active", CreatedAt: at, UpdatedAt: at,
|
||||
})
|
||||
|
||||
status, got := call(t, app, member(http.MethodGet, "/v1/o11y/users/me", nil))
|
||||
@@ -220,22 +173,19 @@ func TestUserWithRolesAnswerIsTheRuntimeAnswer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The create forwards the caller's postable user through the runtime's own type,
|
||||
// field for field, and answers with the runtime's 201 — the created status the
|
||||
// mux registration always carried.
|
||||
func TestCreateUserForwardsTheBody(t *testing.T) {
|
||||
// The org update forwards the caller's body through the face's own type, field
|
||||
// for field.
|
||||
func TestUpdateMyOrgForwardsTheBody(t *testing.T) {
|
||||
app := mounted(t)
|
||||
_, asked := runtime(t, o11y.O11yCreated{ID: "u9"})
|
||||
_, asked := runtime(t, struct{}{})
|
||||
|
||||
sent := `{"displayName":"Ada","email":"[email protected]","frontendBaseUrl":"https://console.example.com",` +
|
||||
`"userRoles":[{"id":"r1"},{"id":"r2"}]}`
|
||||
status, got := call(t, app, member(http.MethodPost, "/v1/o11y/users", strings.NewReader(sent)))
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s, want 201", status, got)
|
||||
sent := `{"displayName":"Max Power","name":"maxpower","alias":"mp"}`
|
||||
if status, got := call(t, app, member(http.MethodPut, "/v1/o11y/orgs/me", strings.NewReader(sent))); status != http.StatusNoContent {
|
||||
t.Fatalf("status=%d body=%s, want 204", status, got)
|
||||
}
|
||||
|
||||
forwarded, _ := io.ReadAll((*asked).Body)
|
||||
var want, have o11y.O11yPostableUser
|
||||
var want, have o11y.O11yOrganization
|
||||
if err := json.Unmarshal([]byte(sent), &want); err != nil {
|
||||
t.Fatalf("unmarshal sent: %v", err)
|
||||
}
|
||||
@@ -247,41 +197,14 @@ func TestCreateUserForwardsTheBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sign-in answers the runtime's token pair, to the byte, and the runtime receives
|
||||
// the caller's own credentials — the body a session is minted from is not
|
||||
// rewritten on the way through.
|
||||
func TestSessionTokenAnswerIsTheRuntimeAnswer(t *testing.T) {
|
||||
app := mounted(t)
|
||||
wrote, asked := runtime(t, o11y.O11yToken{TokenType: "bearer", AccessToken: "acc.jwt", RefreshToken: "ref.jwt", ExpiresIn: 3600})
|
||||
|
||||
sent := `{"email":"[email protected]","password":"hunter2","orgId":"maxpower"}`
|
||||
status, got := call(t, app, member(http.MethodPost, "/v1/o11y/sessions/email_password", strings.NewReader(sent)))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s, want 200", status, got)
|
||||
}
|
||||
if !bytes.Equal(got, *wrote) {
|
||||
t.Fatalf("the op changed the bytes.\n runtime: %s\n op: %s", *wrote, got)
|
||||
}
|
||||
forwarded, _ := io.ReadAll((*asked).Body)
|
||||
var want, have o11y.O11yEmailPasswordSessionIn
|
||||
if err := json.Unmarshal([]byte(sent), &want); err != nil {
|
||||
t.Fatalf("unmarshal sent: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(forwarded, &have); err != nil {
|
||||
t.Fatalf("the runtime was sent something it cannot read: %v (%s)", err, forwarded)
|
||||
}
|
||||
if a, b := mustJSON(t, want), mustJSON(t, have); a != b {
|
||||
t.Fatalf("the op rewrote the credentials.\n caller: %s\n runtime: %s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// The caller's identity travels on as the gateway asserted it — propagated, not
|
||||
// minted, and not invented when there is none.
|
||||
// minted, and not invented when there is none. This is the ONLY way a caller is
|
||||
// identified now, which is why it is proved on the identity face itself.
|
||||
func TestIdentityPropagatesTheCaller(t *testing.T) {
|
||||
app := mounted(t)
|
||||
_, asked := runtime(t, []any{})
|
||||
_, asked := runtime(t, o11y.O11yUser{ID: "u1"})
|
||||
|
||||
r := member(http.MethodGet, "/v1/o11y/users", nil)
|
||||
r := member(http.MethodGet, "/v1/o11y/users/me", nil)
|
||||
r.Header.Set(zip.HeaderUserAdmin, "true")
|
||||
r.Header.Set(zip.HeaderProject, "proj-9")
|
||||
if status, body := call(t, app, r); status != http.StatusOK {
|
||||
@@ -308,9 +231,7 @@ func TestIdentityFailsClosedWithoutARuntime(t *testing.T) {
|
||||
o11y.SetRuntime(nil)
|
||||
|
||||
for _, tc := range []struct{ method, target string }{
|
||||
{http.MethodGet, "/v1/o11y/users"},
|
||||
{http.MethodGet, "/v1/o11y/users/me"},
|
||||
{http.MethodGet, "/v1/o11y/domains"},
|
||||
{http.MethodGet, "/v1/o11y/orgs/me"},
|
||||
{http.MethodGet, "/v1/o11y/user/preferences"},
|
||||
} {
|
||||
@@ -320,11 +241,9 @@ func TestIdentityFailsClosedWithoutARuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// THE POINT OF THE PORT: every one of the forty-five ops is in the document now,
|
||||
// each with its operation id and its prose. A route behind the wildcard had none
|
||||
// of that — no SDK method, no command, no agent tool, no reference page — which
|
||||
// is what made a tenant's own membership, sign-in and preferences unreachable
|
||||
// from anything but a hand-written call.
|
||||
// Every surviving op is in the document, with its operation id and its prose —
|
||||
// and every deleted one is NOT, because a document that still advertises a
|
||||
// password reset is a client that will call one.
|
||||
func TestIdentityReachesTheDocument(t *testing.T) {
|
||||
app := mounted(t)
|
||||
raw, err := json.Marshal(app.OpenAPISpec())
|
||||
@@ -356,10 +275,15 @@ func TestIdentityReachesTheDocument(t *testing.T) {
|
||||
t.Errorf("%s %s has no prose in the document: %q", op.Method, path, doc.Summary)
|
||||
}
|
||||
}
|
||||
// The three redirect callbacks are deliberately absent from the document.
|
||||
for _, p := range []string{"/v1/o11y/complete/google", "/v1/o11y/complete/saml", "/v1/o11y/complete/oidc"} {
|
||||
|
||||
for _, p := range []string{
|
||||
"/v1/o11y/invite", "/v1/o11y/users", "/v1/o11y/users/{id}", "/v1/o11y/sessions",
|
||||
"/v1/o11y/sessions/rotate", "/v1/o11y/sessions/email_password", "/v1/o11y/domains",
|
||||
"/v1/o11y/resetPassword", "/v1/o11y/factor_password/forgot",
|
||||
"/v1/o11y/users/me/factor_password", "/v1/o11y/complete/google",
|
||||
} {
|
||||
if _, there := spec.Paths[p]; there {
|
||||
t.Errorf("%s entered the document as a typed op — it answers a 303 redirect, and the document would lie about it", p)
|
||||
t.Errorf("%s is still in the document — a generated client would still call it", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ import (
|
||||
// installed they 503 with a clear error rather than pretending.
|
||||
//
|
||||
// EVERY ROUTE IS NAMED. There is no /v1/o11y/* catch-all any more. The runtime
|
||||
// registers 366 method+path pairs; 356 of them are typed ops declared in the
|
||||
// slice files below, and the remaining 10 are registered one by one in
|
||||
// registers 329 method+path pairs; 322 of them are typed ops declared in the
|
||||
// slice files below, and the remaining 7 are registered one by one in
|
||||
// mountHatches with the reason each cannot be typed written next to it. A
|
||||
// catch-all hides the difference between "converted" and "not converted" — the
|
||||
// dark-slice defect this package already shipped once was invisible precisely
|
||||
@@ -88,11 +88,11 @@ func Use(app *zip.App, opts ...Option) error {
|
||||
// a hatch because it is a stream. Same construction: they answer on the
|
||||
// runtime, so the wire is unchanged; see logs.go.
|
||||
mountLogs(app)
|
||||
// The TYPED identity ops — users, invites, passwords, roles-on-users,
|
||||
// sessions, auth domains, my-org, preferences and quick filters. Same
|
||||
// construction: named In, named Out, answered on the runtime, wire
|
||||
// unchanged; see identity.go. The three /complete/* sign-in callbacks stay
|
||||
// hatches — they answer with redirects, not JSON.
|
||||
// The TYPED identity ops — who the caller is, my-org, quick filters and
|
||||
// preferences. Same construction: named In, named Out, answered on the
|
||||
// runtime, wire unchanged; see identity.go. Invites, passwords, reset
|
||||
// tokens, member administration, sessions, SSO domains and the /complete/*
|
||||
// sign-in callbacks are GONE, not hatched: Hanzo IAM owns credentials.
|
||||
mountIdentity(app)
|
||||
// The TYPED access-control ops — roles, service accounts, service-account
|
||||
// keys and the authorization probe. Same construction: named In, named Out,
|
||||
@@ -164,12 +164,12 @@ func Use(app *zip.App, opts ...Option) error {
|
||||
// The TYPED span-mapper ops — the ingest-time rules that move or copy span
|
||||
// attributes into resource attributes; see spanmappers.go.
|
||||
mountSpanMappers(app)
|
||||
// And the ten that cannot be typed, each named and justified.
|
||||
// And the seven that cannot be typed, each named and justified.
|
||||
mountHatches(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mountHatches registers the TEN routes that cannot be typed ops, one route
|
||||
// mountHatches registers the SEVEN routes that cannot be typed ops, one route
|
||||
// literal each, with the reason next to it. This list is meant to shrink and is
|
||||
// meant to be hard to grow: adding to it costs a justification in review, where
|
||||
// a catch-all cost nothing.
|
||||
@@ -181,7 +181,7 @@ func Use(app *zip.App, opts ...Option) error {
|
||||
// than being absent from it — a generated client that trusts a false contract
|
||||
// fails at the customer, not at review.
|
||||
//
|
||||
// Two of these ten sit OUTSIDE /v1/o11y — the ingest routes on eventRoot —
|
||||
// Two of these seven sit OUTSIDE /v1/o11y — the ingest routes on eventRoot —
|
||||
// which is the second thing a wildcard hides. A catch-all on one root cannot
|
||||
// reach a route on another, so it does not merely obscure which routes are
|
||||
// un-typed, it obscures which are missing: both were unreachable from the
|
||||
@@ -202,16 +202,7 @@ func mountHatches(app *zip.App) {
|
||||
route(app, http.MethodGet, o11yRoot+"/query_progress") // progress: a long poll, or a websocket when the caller upgrades
|
||||
route(app, http.MethodPost, o11yRoot+"/export_raw_data") // chunked CSV/JSONL attachment, X-Response-Complete trailer
|
||||
|
||||
// ── 2. REDIRECTS: the answer is a Location, not a body ───────────────────
|
||||
// The three sign-in callbacks answer 303 with a Location header and no
|
||||
// payload. A typed op declares a 2xx JSON contract, so typing these would
|
||||
// publish a response schema for a response that does not exist, and hide the
|
||||
// header that is the entire point of the call.
|
||||
route(app, http.MethodGet, o11yRoot+"/complete/google") // Google OIDC callback → 303 to the console
|
||||
route(app, http.MethodGet, o11yRoot+"/complete/oidc") // generic OIDC callback → 303
|
||||
route(app, http.MethodPost, o11yRoot+"/complete/saml") // SAML assertion consumer → 303
|
||||
|
||||
// ── 3. A FOREIGN PROTOCOL WE RECEIVE ─────────────────────────────────────
|
||||
// ── 2. A FOREIGN PROTOCOL WE RECEIVE ─────────────────────────────────────
|
||||
// Sentry-compatible ingest, and the only thing in this table that is not a
|
||||
// face. The body is an application/x-sentry-envelope frame, not JSON, and
|
||||
// the caller is a Sentry SDK authenticating with a DSN public key rather
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// BY NAME, NOT BY COUNT.
|
||||
//
|
||||
// routes_test.go counts this registrar's routes and the module's declaration
|
||||
// counts its own, and 233 + 134 = 367 on both sides. Two sets of 367 can be 367
|
||||
// counts its own, and 197 + 132 = 329 on both sides. Two sets of 329 can be 329
|
||||
// DIFFERENT addresses and every count in the repo stays green — which is not a
|
||||
// hypothetical: three of these routes bound {traceID} where the declaration
|
||||
// named {traceId}, and nothing could see it, because a router matches by
|
||||
@@ -26,9 +26,9 @@ import (
|
||||
// rather than against a list.
|
||||
//
|
||||
// Equality follows without a second test: this file and its sibling in
|
||||
// pkg/query-service/app each prove registered ⊆ declared, their counts are 233
|
||||
// and 134, the declaration's is 367, and a duplicate registration panics at boot
|
||||
// (routing.Handle). 233 + 134 distinct addresses inside a set of 367 is the whole
|
||||
// pkg/query-service/app each prove registered ⊆ declared, their counts are 197
|
||||
// and 132, the declaration's is 329, and a duplicate registration panics at boot
|
||||
// (routing.Handle). 197 + 132 distinct addresses inside a set of 329 is the whole
|
||||
// set.
|
||||
func TestEveryRegisteredAddressIsDeclaredAndResolves(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package o11yapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/http/handler"
|
||||
"github.com/hanzoai/o11y/pkg/http/routing"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
// ALL of these routes are ALSO declared as typed ops at the module's mount seam
|
||||
// (identity.go in the repo root) — a second DISPATCH onto this router, never a
|
||||
// second implementation; the AdminAccess gate below stays the one place access
|
||||
// is decided. The ops serve the composed binary, this router the standalone
|
||||
// process.
|
||||
func (provider *provider) addAuthDomainRoutes(router routing.Router) {
|
||||
router.Get("/v1/o11y/domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{
|
||||
ID: "ListAuthDomains",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "List all auth domains",
|
||||
Description: "This endpoint lists all auth domains",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.GettableAuthDomain, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{
|
||||
ID: "CreateAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Create auth domain",
|
||||
Description: "This endpoint creates an auth domain",
|
||||
Request: new(authtypes.PostableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{
|
||||
ID: "GetAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Get auth domain by ID",
|
||||
Description: "This endpoint returns an auth domain by ID",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.GettableAuthDomain),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Put("/v1/o11y/domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{
|
||||
ID: "UpdateAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Update auth domain",
|
||||
Description: "This endpoint updates an auth domain",
|
||||
Request: new(authtypes.UpdatableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Delete("/v1/o11y/domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{
|
||||
ID: "DeleteAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Delete auth domain",
|
||||
Description: "This endpoint deletes an auth domain",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/http/handler"
|
||||
"github.com/hanzoai/o11y/pkg/http/middleware"
|
||||
"github.com/hanzoai/o11y/pkg/http/routing"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain"
|
||||
"github.com/hanzoai/o11y/pkg/modules/cloudintegration"
|
||||
"github.com/hanzoai/o11y/pkg/modules/dashboard"
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking"
|
||||
@@ -30,7 +29,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/modules/rulestatehistory"
|
||||
"github.com/hanzoai/o11y/pkg/modules/sentry"
|
||||
"github.com/hanzoai/o11y/pkg/modules/serviceaccount"
|
||||
"github.com/hanzoai/o11y/pkg/modules/session"
|
||||
"github.com/hanzoai/o11y/pkg/modules/spanmapper"
|
||||
"github.com/hanzoai/o11y/pkg/modules/tracedetail"
|
||||
"github.com/hanzoai/o11y/pkg/modules/user"
|
||||
@@ -49,8 +47,6 @@ type provider struct {
|
||||
authzService authz.AuthZ
|
||||
orgHandler organization.Handler
|
||||
userHandler user.Handler
|
||||
sessionHandler session.Handler
|
||||
authDomainHandler authdomain.Handler
|
||||
preferenceHandler preference.Handler
|
||||
globalHandler global.Handler
|
||||
promoteHandler promote.Handler
|
||||
@@ -86,8 +82,6 @@ func NewFactory(
|
||||
authzService authz.AuthZ,
|
||||
orgHandler organization.Handler,
|
||||
userHandler user.Handler,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
preferenceHandler preference.Handler,
|
||||
globalHandler global.Handler,
|
||||
promoteHandler promote.Handler,
|
||||
@@ -126,8 +120,6 @@ func NewFactory(
|
||||
authzService,
|
||||
orgHandler,
|
||||
userHandler,
|
||||
sessionHandler,
|
||||
authDomainHandler,
|
||||
preferenceHandler,
|
||||
globalHandler,
|
||||
promoteHandler,
|
||||
@@ -168,8 +160,6 @@ func newProvider(
|
||||
authzService authz.AuthZ,
|
||||
orgHandler organization.Handler,
|
||||
userHandler user.Handler,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
preferenceHandler preference.Handler,
|
||||
globalHandler global.Handler,
|
||||
promoteHandler promote.Handler,
|
||||
@@ -206,8 +196,6 @@ func newProvider(
|
||||
orgHandler: orgHandler,
|
||||
userHandler: userHandler,
|
||||
authzService: authzService,
|
||||
sessionHandler: sessionHandler,
|
||||
authDomainHandler: authDomainHandler,
|
||||
preferenceHandler: preferenceHandler,
|
||||
globalHandler: globalHandler,
|
||||
promoteHandler: promoteHandler,
|
||||
@@ -248,8 +236,6 @@ func newProvider(
|
||||
|
||||
func (provider *provider) AddToRouter(router routing.Router) {
|
||||
provider.addOrgRoutes(router)
|
||||
provider.addSessionRoutes(router)
|
||||
provider.addAuthDomainRoutes(router)
|
||||
provider.addPreferenceRoutes(router)
|
||||
provider.addUserRoutes(router)
|
||||
provider.addGlobalRoutes(router)
|
||||
@@ -295,6 +281,6 @@ func newAnonymousSecuritySchemes(scopes []string) []handler.OpenAPISecuritySchem
|
||||
func newScopedSecuritySchemes(scopes []string) []handler.OpenAPISecurityScheme {
|
||||
return []handler.OpenAPISecurityScheme{
|
||||
{Name: authtypes.IdentNProviderAPIKey.StringValue(), Scopes: scopes},
|
||||
{Name: authtypes.IdentNProviderTokenizer.StringValue(), Scopes: scopes},
|
||||
{Name: authtypes.IdentNProviderIAM.StringValue(), Scopes: scopes},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/global"
|
||||
"github.com/hanzoai/o11y/pkg/http/middleware"
|
||||
"github.com/hanzoai/o11y/pkg/http/routing"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain"
|
||||
"github.com/hanzoai/o11y/pkg/modules/cloudintegration"
|
||||
"github.com/hanzoai/o11y/pkg/modules/dashboard"
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking"
|
||||
@@ -31,7 +30,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/modules/rulestatehistory"
|
||||
"github.com/hanzoai/o11y/pkg/modules/sentry"
|
||||
"github.com/hanzoai/o11y/pkg/modules/serviceaccount"
|
||||
"github.com/hanzoai/o11y/pkg/modules/session"
|
||||
"github.com/hanzoai/o11y/pkg/modules/spanmapper"
|
||||
"github.com/hanzoai/o11y/pkg/modules/tracedetail"
|
||||
"github.com/hanzoai/o11y/pkg/modules/user"
|
||||
@@ -45,7 +43,7 @@ import (
|
||||
// THE API SERVER'S CENSUS.
|
||||
//
|
||||
// This package registers the larger half of the service's HTTP surface, and it
|
||||
// registers it by calling 32 functions from one place. An uncalled func is legal
|
||||
// registers it by calling 30 functions from one place. An uncalled func is legal
|
||||
// Go, so a slice that stops being called compiles, passes every test it has, and
|
||||
// serves nothing — which is exactly what this repo already shipped once, with 83
|
||||
// typed ops present in the source and reaching no router at all. Only arithmetic
|
||||
@@ -53,7 +51,15 @@ import (
|
||||
//
|
||||
// If this number moves, a route was added or removed and someone has to say
|
||||
// which. It is not a target to satisfy by editing the constant.
|
||||
const wantAPIServerRoutes = 233
|
||||
//
|
||||
// It was 233. Thirty-six routes left when the duplicate identity system did:
|
||||
// twenty-four user routes (invites, passwords, reset tokens, member listing and
|
||||
// editing, roles-on-users), seven session routes including the three /complete/*
|
||||
// sign-in callbacks, and five SSO auth-domain routes. addSessionRoutes and
|
||||
// addAuthDomainRoutes are not merely uncalled — the files are deleted, which is
|
||||
// the only shape of removal this census can tell apart from the defect it
|
||||
// exists to catch.
|
||||
const wantAPIServerRoutes = 197
|
||||
|
||||
// every handler field satisfied by an embedded nil interface: the route table is
|
||||
// a fact about REGISTRATION, and registration only takes method values off these
|
||||
@@ -65,8 +71,6 @@ func censusProvider() *provider {
|
||||
authzService: struct{ authz.AuthZ }{},
|
||||
orgHandler: struct{ organization.Handler }{},
|
||||
userHandler: struct{ user.Handler }{},
|
||||
sessionHandler: struct{ session.Handler }{},
|
||||
authDomainHandler: struct{ authdomain.Handler }{},
|
||||
preferenceHandler: struct{ preference.Handler }{},
|
||||
globalHandler: struct{ global.Handler }{},
|
||||
promoteHandler: struct{ promote.Handler }{},
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package o11yapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/http/handler"
|
||||
"github.com/hanzoai/o11y/pkg/http/routing"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
// The four JSON routes here (email_password, context, rotate, delete) are ALSO
|
||||
// declared as typed ops at the module's mount seam (identity.go in the repo
|
||||
// root) — a second DISPATCH onto this router, never a second implementation;
|
||||
// the composed binary serves them as ops, this router serves the standalone
|
||||
// process. The three /complete/* callbacks are NOT typed: they answer with 303
|
||||
// redirects whatever happens, and a typed op declares a 2xx JSON contract,
|
||||
// which would be a lie about them. They reach this router through the
|
||||
// delegation wildcard in both deployments.
|
||||
func (provider *provider) addSessionRoutes(router routing.Router) {
|
||||
router.Post("/v1/o11y/sessions/email_password", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.CreateSessionByEmailPassword), handler.OpenAPIDef{
|
||||
ID: "CreateSessionByEmailPassword",
|
||||
Tags: []string{"sessions"},
|
||||
Summary: "Create session by email and password",
|
||||
Description: "This endpoint creates a session for a user using email and password.",
|
||||
Request: new(authtypes.PostableEmailPasswordSession),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(authtypes.GettableToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/sessions/context", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.GetSessionContext), handler.OpenAPIDef{
|
||||
ID: "GetSessionContext",
|
||||
Tags: []string{"sessions"},
|
||||
Summary: "Get session context",
|
||||
Description: "This endpoint returns the context for the session",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.SessionContext),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/sessions/rotate", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.RotateSession), handler.OpenAPIDef{
|
||||
ID: "RotateSession",
|
||||
Tags: []string{"sessions"},
|
||||
Summary: "Rotate session",
|
||||
Description: "This endpoint rotates the session",
|
||||
Request: new(authtypes.PostableRotateToken),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(authtypes.GettableToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
|
||||
router.Delete("/v1/o11y/sessions", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.DeleteSession), handler.OpenAPIDef{
|
||||
ID: "DeleteSession",
|
||||
Tags: []string{"sessions"},
|
||||
Summary: "Delete session",
|
||||
Description: "This endpoint deletes the session",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/complete/google", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.CreateSessionByGoogleCallback), handler.OpenAPIDef{
|
||||
ID: "CreateSessionByGoogleCallback",
|
||||
Tags: []string{"sessions"},
|
||||
Summary: "Create session by google callback",
|
||||
Description: "This endpoint creates a session for a user using google callback",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.GettableToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusSeeOther,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/complete/saml", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.CreateSessionBySAMLCallback), handler.OpenAPIDef{
|
||||
ID: "CreateSessionBySAMLCallback",
|
||||
Tags: []string{"sessions"},
|
||||
Summary: "Create session by saml callback",
|
||||
Description: "This endpoint creates a session for a user using saml callback",
|
||||
Request: struct {
|
||||
RelayState string `form:"RelayState"`
|
||||
SAMLResponse string `form:"SAMLResponse"`
|
||||
}{},
|
||||
RequestContentType: "application/x-www-form-urlencoded",
|
||||
Response: new(authtypes.GettableToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusSeeOther,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/complete/oidc", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.CreateSessionByOIDCCallback), handler.OpenAPIDef{
|
||||
ID: "CreateSessionByOIDCCallback",
|
||||
Tags: []string{"sessions"},
|
||||
Summary: "Create session by oidc callback",
|
||||
Description: "This endpoint creates a session for a user using oidc callback",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.GettableToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusSeeOther,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
}
|
||||
@@ -5,100 +5,39 @@ import (
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/http/handler"
|
||||
"github.com/hanzoai/o11y/pkg/http/routing"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
// ALL of these routes are ALSO declared as typed ops at the module's mount seam
|
||||
// (identity.go in the repo root, minus none of this file), which is what carries
|
||||
// them into the composed document, the SDK, the CLI and the agent surface. That
|
||||
// is a second DISPATCH, never a second implementation: the ops answer by handing
|
||||
// the call to this router, so the handlers below stay the one place the work is
|
||||
// performed — and the gates declared here (AdminAccess, SelfAccess, OpenAccess)
|
||||
// stay the one place access is decided. Both halves are needed — the ops serve
|
||||
// the composed binary, this router serves the standalone process, which has no
|
||||
// native router to register an op on — so deleting either drops one of the two
|
||||
// deployments.
|
||||
// ONE user route survives, and it is a READ of the caller's OWN identity.
|
||||
//
|
||||
// o11y used to answer twenty-five routes here: invites, passwords, reset
|
||||
// tokens, member listing, member editing, role assignment. Every one was a
|
||||
// SECOND ANSWER to a question Hanzo IAM already answers, and a second answer is
|
||||
// a second place to get it wrong — a password store o11y must hash and rotate,
|
||||
// an invite link o11y must expire, a member edit that disagrees with the one
|
||||
// IAM holds. They are DELETED, with their handlers, their stores and their
|
||||
// tables, not disabled: a surface that is only turned off is still a surface
|
||||
// someone turns back on.
|
||||
//
|
||||
// GetMyUser stays because the console cannot render without knowing WHO it is
|
||||
// rendering for, and this is the one identity question o11y can answer from
|
||||
// what the edge already told it. It reads the CLAIMS, not a row (see
|
||||
// impluser.GetMyUser). That is the whole of the difference: o11y REPORTS the
|
||||
// identity it was handed; it does not keep one.
|
||||
//
|
||||
// OpenAccess, unchanged: the gate on this route has always been "is anyone
|
||||
// authenticated", never a role, because the answer is about the caller alone.
|
||||
//
|
||||
// It is also declared as a typed op at the module's mount seam (identity.go in
|
||||
// the repo root), which carries it into the composed document, the SDK, the CLI
|
||||
// and the agent surface. That is a second DISPATCH, never a second
|
||||
// implementation: the op answers by handing the call to this router.
|
||||
func (provider *provider) addUserRoutes(router routing.Router) {
|
||||
router.Post("/v1/o11y/invite", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateInvite), handler.OpenAPIDef{
|
||||
ID: "CreateInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create invite",
|
||||
Description: "This endpoint creates an invite for a user",
|
||||
Request: new(types.PostableInvite),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Invite),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/invite/bulk", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateBulkInvite), handler.OpenAPIDef{
|
||||
ID: "CreateBulkInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create bulk invite",
|
||||
Description: "This endpoint creates a bulk invite for a user",
|
||||
Request: new(types.PostableBulkInviteRequest),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/user", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsersDeprecated), handler.OpenAPIDef{
|
||||
ID: "ListUsersDeprecated",
|
||||
Tags: []string{"users"},
|
||||
Summary: "List users",
|
||||
Description: "This endpoint lists all users",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.DeprecatedUser, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
|
||||
ID: "ListUsers",
|
||||
Tags: []string{"users"},
|
||||
Summary: "List users v2",
|
||||
Description: "This endpoint lists all users for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/user/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.GetMyUserDeprecated), handler.OpenAPIDef{
|
||||
ID: "GetMyUserDeprecated",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get my user",
|
||||
Description: "This endpoint returns the user I belong to",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.DeprecatedUser),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/users/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.GetMyUser), handler.OpenAPIDef{
|
||||
ID: "GetMyUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get my user v2",
|
||||
Description: "This endpoint returns the user I belong to",
|
||||
Summary: "Get my user",
|
||||
Description: "This endpoint returns the identity the edge asserted for the caller",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserWithRoles),
|
||||
@@ -106,291 +45,6 @@ func (provider *provider) addUserRoutes(router routing.Router) {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
|
||||
ID: "CreateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user",
|
||||
Description: "This endpoint creates a user for the organization",
|
||||
Request: new(authtypes.PostableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Put("/v1/o11y/users/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.UpdateMyUser), handler.OpenAPIDef{
|
||||
ID: "UpdateMyUserV2",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Update my user v2",
|
||||
Description: "This endpoint updates the user I belong to",
|
||||
Request: new(types.UpdatableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/user/{id}", handler.New(provider.authzMiddleware.SelfAccess(provider.userHandler.GetUserDeprecated), handler.OpenAPIDef{
|
||||
ID: "GetUserDeprecated",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user",
|
||||
Description: "This endpoint returns the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.DeprecatedUser),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
|
||||
ID: "GetUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user by user id",
|
||||
Description: "This endpoint returns the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserWithRoles),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Put("/v1/o11y/user/{id}", handler.New(provider.authzMiddleware.SelfAccess(provider.userHandler.UpdateUserDeprecated), handler.OpenAPIDef{
|
||||
ID: "UpdateUserDeprecated",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Update user",
|
||||
Description: "This endpoint updates the user by id",
|
||||
Request: new(types.DeprecatedUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.DeprecatedUser),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Put("/v1/o11y/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
|
||||
ID: "UpdateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Update user v2",
|
||||
Description: "This endpoint updates the user by id",
|
||||
Request: new(types.UpdatableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Delete("/v1/o11y/user/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
|
||||
ID: "DeleteUserDeprecated",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user",
|
||||
Description: "This endpoint deletes the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Delete("/v1/o11y/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
|
||||
ID: "DeleteUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user",
|
||||
Description: "This endpoint deletes the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/getResetPasswordToken/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetResetPasswordTokenDeprecated), handler.OpenAPIDef{
|
||||
ID: "GetResetPasswordTokenDeprecated",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get reset password token",
|
||||
Description: "This endpoint returns the reset password token by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetResetPasswordToken), handler.OpenAPIDef{
|
||||
ID: "GetResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get reset password token for a user",
|
||||
Description: "This endpoint returns the existing reset password token for a user.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Put("/v1/o11y/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateResetPasswordToken), handler.OpenAPIDef{
|
||||
ID: "CreateResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create or regenerate reset password token for a user",
|
||||
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/reset_password_tokens/verify", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.VerifyResetPasswordToken), handler.OpenAPIDef{
|
||||
ID: "VerifyResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Verify a reset password token",
|
||||
Description: "This endpoint verifies whether a reset password token exists and is not expired",
|
||||
Request: new(types.PostableVerifyResetPasswordToken),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/resetPassword", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ResetPassword), handler.OpenAPIDef{
|
||||
ID: "ResetPassword",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Reset password",
|
||||
Description: "This endpoint resets the password by token",
|
||||
Request: new(types.PostableResetPassword),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
|
||||
router.Put("/v1/o11y/users/me/factor_password", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ChangePassword), handler.OpenAPIDef{
|
||||
ID: "UpdateMyPassword",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Updates my password",
|
||||
Description: "This endpoint updates the password of the user I belong to",
|
||||
Request: new(types.ChangePasswordRequest),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/factor_password/forgot", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ForgotPassword), handler.OpenAPIDef{
|
||||
ID: "ForgotPassword",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Forgot password",
|
||||
Description: "This endpoint initiates the forgot password flow by sending a reset password email",
|
||||
Request: new(types.PostableForgotPassword),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnprocessableEntity},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
|
||||
ID: "GetRolesByUserID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user roles",
|
||||
Description: "This endpoint returns the user roles by user id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Post("/v1/o11y/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.SetRoleByUserID), handler.OpenAPIDef{
|
||||
ID: "SetRoleByUserID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Set user roles",
|
||||
Description: "This endpoint assigns the role to the user roles by user id",
|
||||
Request: new(types.PostableRole),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Delete("/v1/o11y/users/{id}/roles/{roleId}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.RemoveUserRoleByRoleID), handler.OpenAPIDef{
|
||||
ID: "RemoveUserRoleByUserIDAndRoleID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Remove a role from user",
|
||||
Description: "This endpoint removes a role from the user by user id and role id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
}))
|
||||
|
||||
router.Get("/v1/o11y/roles/{id}/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUsersByRoleID), handler.OpenAPIDef{
|
||||
ID: "GetUsersByRoleID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get users by role id",
|
||||
Description: "This endpoint returns the users having the role by role id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderIAM.StringValue()}},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// This can either be a password authn or a callback authn.
|
||||
type AuthN interface{}
|
||||
|
||||
type PasswordAuthN interface {
|
||||
// Authenticate a user using email, password and orgID
|
||||
Authenticate(context.Context, string, string, valuer.UUID) (*authtypes.Identity, error)
|
||||
}
|
||||
|
||||
type CallbackAuthN interface {
|
||||
// The initial URL to redirect the user to. Takes the site url and org domain to be used in the callback.
|
||||
LoginURL(context.Context, *url.URL, *authtypes.AuthDomain) (string, error)
|
||||
|
||||
// Handle the callback from the provider.
|
||||
HandleCallback(context.Context, url.Values) (*authtypes.CallbackIdentity, error)
|
||||
|
||||
// Get provider info such as `relay state`
|
||||
ProviderInfo(context.Context, *authtypes.AuthDomain) *authtypes.AuthNProviderInfo
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package sqlauthnstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) authtypes.AuthNStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, []*authtypes.UserRole, error) {
|
||||
user := new(types.User)
|
||||
factorPassword := new(types.FactorPassword)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(user).
|
||||
Where("email = ?", email).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("status = ?", types.UserStatusActive.StringValue()).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with email %s in org %s not found", email, orgID)
|
||||
}
|
||||
|
||||
err = store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(factorPassword).
|
||||
Where("user_id = ?", user.ID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodePasswordNotFound, "user with email %s in org %s does not have password", email, orgID)
|
||||
}
|
||||
|
||||
userRoles := make([]*authtypes.UserRole, 0)
|
||||
err = store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&userRoles).
|
||||
Where("user_id = ?", user.ID).
|
||||
Relation("Role").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get user roles for user %s in org %s", email, orgID)
|
||||
}
|
||||
|
||||
return user, factorPassword, userRoles, nil
|
||||
}
|
||||
|
||||
func (store *store) GetAuthDomainFromID(ctx context.Context, domainID valuer.UUID) (*authtypes.AuthDomain, error) {
|
||||
storableAuthDomain := new(authtypes.StorableAuthDomain)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storableAuthDomain).
|
||||
Where("id = ?", domainID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeAuthDomainNotFound, "auth domain with id %s does not exist", domainID)
|
||||
}
|
||||
|
||||
return authtypes.NewAuthDomainFromStorableAuthDomain(storableAuthDomain)
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
package googlecallbackauthn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
admin "google.golang.org/api/admin/directory/v1"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/authn"
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/global"
|
||||
"github.com/hanzoai/o11y/pkg/http/client"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
const (
|
||||
issuerURL string = "https://accounts.google.com"
|
||||
redirectPath string = "/v1/o11y/complete/google"
|
||||
)
|
||||
|
||||
var scopes []string = []string{"email", "profile"}
|
||||
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
store authtypes.AuthNStore
|
||||
settings factory.ScopedProviderSettings
|
||||
httpClient *client.Client
|
||||
globalConfig global.Config
|
||||
}
|
||||
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/authn/callbackauthn/googlecallbackauthn")
|
||||
|
||||
httpClient, err := client.New(settings.Logger(), providerSettings.TracerProvider, providerSettings.MeterProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthN{
|
||||
store: store,
|
||||
settings: settings,
|
||||
httpClient: httpClient,
|
||||
globalConfig: globalConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
oidcProvider, err := oidc.NewProvider(ctx, issuerURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderGoogleAuth {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not google")
|
||||
}
|
||||
|
||||
oauth2Config := a.oauth2Config(siteURL, authDomain, oidcProvider)
|
||||
|
||||
return oauth2Config.AuthCodeURL(
|
||||
authtypes.NewState(siteURL, authDomain.StorableAuthDomain().ID).URL.String(),
|
||||
oauth2.SetAuthURLParam("hd", authDomain.StorableAuthDomain().Name),
|
||||
), nil
|
||||
}
|
||||
|
||||
func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtypes.CallbackIdentity, error) {
|
||||
oidcProvider, err := oidc.NewProvider(ctx, issuerURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := query.Get("error"); err != "" {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: error while authenticating", slog.String("error", err), slog.String("error_description", query.Get("error_description")))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: error while authenticating").WithAdditional(query.Get("error_description"))
|
||||
}
|
||||
|
||||
state, err := authtypes.NewStateFromString(query.Get("state"))
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: invalid state", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "google: invalid state").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
authDomain, err := a.store.GetAuthDomainFromID(ctx, state.DomainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oauth2Config := a.oauth2Config(state.URL, authDomain, oidcProvider)
|
||||
token, err := oauth2Config.Exchange(ctx, query.Get("code"))
|
||||
if err != nil {
|
||||
var retrieveError *oauth2.RetrieveError
|
||||
if errors.As(err, &retrieveError) {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: failed to get token", errors.Attr(err), slog.String("error_description", retrieveError.ErrorDescription), slog.String("body", string(retrieveError.Body)))
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to get token").WithAdditional(retrieveError.ErrorDescription)
|
||||
}
|
||||
|
||||
a.settings.Logger().ErrorContext(ctx, "google: failed to get token", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: failed to get token")
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google: no id_token in token response")
|
||||
}
|
||||
|
||||
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: failed to verify token", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to verify token")
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
HostedDomain string `json:"hd"`
|
||||
}
|
||||
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: missing or invalid claims", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: missing or invalid claims").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if claims.HostedDomain != authDomain.StorableAuthDomain().Name {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: unexpected hd claim", slog.String("expected", authDomain.StorableAuthDomain().Name), slog.String("actual", claims.HostedDomain))
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim")
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().Google.InsecureSkipEmailVerified {
|
||||
if !claims.EmailVerified {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: email is not verified", slog.String("email", claims.Email))
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: email is not verified")
|
||||
}
|
||||
}
|
||||
|
||||
email, err := valuer.NewEmail(claims.Email)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "google: failed to parse email").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if authDomain.AuthDomainConfig().Google.FetchGroups {
|
||||
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: could not fetch groups", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: could not fetch groups").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
allowedGroups := authDomain.AuthDomainConfig().Google.AllowedGroups
|
||||
if len(allowedGroups) > 0 {
|
||||
groups = filterGroups(groups, allowedGroups)
|
||||
if len(groups) == 0 {
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: user %q is not in any allowed groups", claims.Email).WithAdditional(allowedGroups...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return authtypes.NewCallbackIdentity(claims.Name, email, authDomain.StorableAuthDomain().OrgID, state, groups, ""), nil
|
||||
}
|
||||
|
||||
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
|
||||
return &authtypes.AuthNProviderInfo{
|
||||
RelayStatePath: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain, provider *oidc.Provider) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: authDomain.AuthDomainConfig().Google.ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().Google.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: (&url.URL{
|
||||
Scheme: siteURL.Scheme,
|
||||
Host: siteURL.Host,
|
||||
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
|
||||
}).String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthN) fetchGoogleWorkspaceGroups(ctx context.Context, userEmail string, config *authtypes.GoogleConfig) ([]string, error) {
|
||||
adminEmail := config.GetAdminEmailForDomain(userEmail)
|
||||
if adminEmail == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no admin email configured for domain of %s", userEmail)
|
||||
}
|
||||
|
||||
jwtConfig, err := google.JWTConfigFromJSON([]byte(config.ServiceAccountJSON), admin.AdminDirectoryGroupReadonlyScope)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: invalid service account credentials", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid service account credentials")
|
||||
}
|
||||
|
||||
jwtConfig.Subject = adminEmail
|
||||
|
||||
customCtx := context.WithValue(ctx, oauth2.HTTPClient, a.httpClient.Client())
|
||||
|
||||
adminService, err := admin.NewService(ctx, option.WithHTTPClient(jwtConfig.Client(customCtx)))
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: unable to create directory service", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to create directory service")
|
||||
}
|
||||
|
||||
checkedGroups := make(map[string]struct{})
|
||||
|
||||
return a.getGroups(ctx, adminService, userEmail, config.FetchTransitiveGroupMembership, checkedGroups)
|
||||
}
|
||||
|
||||
// Recursive method.
|
||||
func (a *AuthN) getGroups(ctx context.Context, adminService *admin.Service, userEmail string, fetchTransitive bool, checkedGroups map[string]struct{}) ([]string, error) {
|
||||
var userGroups []string
|
||||
var pageToken string
|
||||
|
||||
for {
|
||||
call := adminService.Groups.List().UserKey(userEmail)
|
||||
if pageToken != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
|
||||
groupList, err := call.Context(ctx).Do()
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: unable to list groups", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to list groups")
|
||||
}
|
||||
|
||||
for _, group := range groupList.Groups {
|
||||
if _, exists := checkedGroups[group.Email]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
checkedGroups[group.Email] = struct{}{}
|
||||
userGroups = append(userGroups, group.Email)
|
||||
|
||||
if fetchTransitive {
|
||||
transitiveGroups, err := a.getGroups(ctx, adminService, group.Email, fetchTransitive, checkedGroups)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: unable to list transitive groups", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to list transitive groups")
|
||||
}
|
||||
userGroups = append(userGroups, transitiveGroups...)
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = groupList.NextPageToken
|
||||
if pageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return userGroups, nil
|
||||
}
|
||||
|
||||
func filterGroups(userGroups, allowedGroups []string) []string {
|
||||
allowed := make(map[string]struct{}, len(allowedGroups))
|
||||
for _, g := range allowedGroups {
|
||||
allowed[g] = struct{}{} // just to make o(1) searches
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, g := range userGroups {
|
||||
if _, ok := allowed[g]; ok {
|
||||
filtered = append(filtered, g)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package emailpasswordauthn
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/authn"
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
var _ authn.PasswordAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
store authtypes.AuthNStore
|
||||
}
|
||||
|
||||
func New(store authtypes.AuthNStore) *AuthN {
|
||||
return &AuthN{store: store}
|
||||
}
|
||||
|
||||
func (a *AuthN) Authenticate(ctx context.Context, email string, password string, orgID valuer.UUID) (*authtypes.Identity, error) {
|
||||
user, factorPassword, _, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !factorPassword.Equals(password) {
|
||||
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
|
||||
}
|
||||
|
||||
return authtypes.NewPrincipalUserIdentity(user.ID, orgID, user.Email, authtypes.IdentNProviderTokenizer), nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package localauthz
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
@@ -146,3 +147,43 @@ func TestRehydratedGrantsStayInTheirOrg(t *testing.T) {
|
||||
t.Fatal("a grant rehydrated for one org authorized the subject in another")
|
||||
}
|
||||
}
|
||||
|
||||
// THE LINK, not the function.
|
||||
//
|
||||
// Every test above calls p.rehydrate directly, and all four stayed GREEN when
|
||||
// the call to it was deleted from Start — which is the whole defect they were
|
||||
// written to prevent, reproduced one layer up. A rehydration nothing invokes is
|
||||
// a lockout with a passing suite, and the thing the process actually runs is
|
||||
// Start.
|
||||
//
|
||||
// So this drives START, on a provider whose only state is durable rows, and
|
||||
// asks the question a request asks. Delete `p.rehydrate(ctx)` from Start and
|
||||
// this goes red; the four above do not.
|
||||
func TestStartRehydrates(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
orgID, userID, roleID := valuer.GenerateUUID(), valuer.GenerateUUID(), valuer.GenerateUUID()
|
||||
p := newProvider(durable(t, userID, valuer.UUID{}, orgID, roleID), factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "test"))
|
||||
|
||||
started := make(chan error, 1)
|
||||
go func() { started <- p.Start(ctx) }()
|
||||
t.Cleanup(func() {
|
||||
_ = p.Stop(context.Background())
|
||||
<-started
|
||||
})
|
||||
|
||||
claims := authtypes.Claims{Principal: authtypes.PrincipalUser, UserID: userID.StringValue()}
|
||||
selectors := []coretypes.Selector{coretypes.TypeRole.MustSelector(adminRole)}
|
||||
|
||||
// Start blocks after rehydrating, so poll rather than sleep on a guess.
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
var err error
|
||||
for time.Now().Before(deadline) {
|
||||
if err = p.CheckWithTupleCreation(ctx, claims, orgID, authtypes.Relation{}, nil, nil, selectors); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Start did not rehydrate the durable grants: %v", err)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/alertmanager"
|
||||
"github.com/hanzoai/o11y/pkg/analytics"
|
||||
"github.com/hanzoai/o11y/pkg/auditor"
|
||||
"github.com/hanzoai/o11y/pkg/authn"
|
||||
"github.com/hanzoai/o11y/pkg/authz"
|
||||
"github.com/hanzoai/o11y/pkg/authz/iamauthz"
|
||||
"github.com/hanzoai/o11y/pkg/authz/localauthz"
|
||||
@@ -54,7 +53,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/sqlschema"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
|
||||
"github.com/hanzoai/o11y/pkg/zeus"
|
||||
"github.com/hanzoai/o11y/pkg/zeus/noopzeus"
|
||||
@@ -119,9 +117,6 @@ func NewO11y(ctx context.Context, config o11y.Config) (*o11y.O11y, error) {
|
||||
SQLSchemaProviderFactories,
|
||||
SQLStoreProviderFactories(),
|
||||
o11y.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return o11y.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
|
||||
},
|
||||
func(_ context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
// Authorization is selected by the TRUST BOUNDARY, not by pluggable policy —
|
||||
// the enforced policy is identical either way, only where the relationship
|
||||
|
||||
@@ -51,7 +51,6 @@ func (provider *provider) GetConfig(context.Context) *globaltypes.Config {
|
||||
aiAssistantURL,
|
||||
),
|
||||
globaltypes.NewIdentNConfig(
|
||||
globaltypes.TokenizerConfig{Enabled: provider.identNConfig.Tokenizer.Enabled},
|
||||
globaltypes.APIKeyConfig{Enabled: provider.identNConfig.APIKeyConfig.Enabled},
|
||||
globaltypes.ImpersonationConfig{Enabled: provider.identNConfig.Impersonation.Enabled},
|
||||
),
|
||||
|
||||
@@ -9,9 +9,6 @@ type Config struct {
|
||||
// Config for the Hanzo IAM session identN resolver (the sole human identity)
|
||||
IAM IAMConfig `mapstructure:"iam"`
|
||||
|
||||
// Config for tokenizer identN resolver
|
||||
Tokenizer TokenizerConfig `mapstructure:"tokenizer"`
|
||||
|
||||
// Config for apikey identN resolver
|
||||
APIKeyConfig APIKeyConfig `mapstructure:"apikey"`
|
||||
|
||||
@@ -32,14 +29,6 @@ type ImpersonationConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type TokenizerConfig struct {
|
||||
// Toggles the identN resolver
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
|
||||
// Headers to extract from incoming requests
|
||||
Headers []string `mapstructure:"headers"`
|
||||
}
|
||||
|
||||
type APIKeyConfig struct {
|
||||
// Toggles the identN resolver
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
@@ -57,10 +46,6 @@ func newConfig() factory.Config {
|
||||
IAM: IAMConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
Tokenizer: TokenizerConfig{
|
||||
Enabled: true,
|
||||
Headers: []string{"Authorization", "Sec-WebSocket-Protocol"},
|
||||
},
|
||||
APIKeyConfig: APIKeyConfig{
|
||||
Enabled: true,
|
||||
Headers: []string{"O11Y-API-KEY"},
|
||||
@@ -77,10 +62,6 @@ func (c Config) Validate() error {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::iam is enabled")
|
||||
}
|
||||
|
||||
if c.Tokenizer.Enabled {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::tokenizer is enabled")
|
||||
}
|
||||
|
||||
if c.APIKeyConfig.Enabled {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::apikey is enabled")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ package identn
|
||||
// impersonation matches unconditionally, so if it is in the list at all, a
|
||||
// request that fails to match IAM falls through to root. The ONLY thing standing
|
||||
// between that and a production host is Config.Validate refusing to boot when
|
||||
// impersonation is enabled alongside IAM, the tokenizer or API keys — a check
|
||||
// impersonation is enabled alongside IAM or API keys — a check
|
||||
// that pkg/o11y/config.go's validateConfig runs over every config at startup, so
|
||||
// a deployment that tries the combination fails to start rather than serving.
|
||||
//
|
||||
@@ -32,13 +32,12 @@ func TestImpersonationCannotBootBesideARealIdentityProvider(t *testing.T) {
|
||||
with func(*Config)
|
||||
}{
|
||||
{"IAM", func(c *Config) { c.IAM.Enabled = true }},
|
||||
{"tokenizer", func(c *Config) { c.Tokenizer.Enabled = true }},
|
||||
{"api key", func(c *Config) { c.APIKeyConfig.Enabled = true }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := newConfig().(*Config)
|
||||
c.Impersonation.Enabled = true
|
||||
c.IAM.Enabled, c.Tokenizer.Enabled, c.APIKeyConfig.Enabled = false, false, false
|
||||
c.IAM.Enabled, c.APIKeyConfig.Enabled = false, false
|
||||
tc.with(c)
|
||||
|
||||
if err := c.Validate(); err == nil {
|
||||
@@ -54,7 +53,7 @@ func TestImpersonationCannotBootBesideARealIdentityProvider(t *testing.T) {
|
||||
func TestImpersonationAloneIsValid(t *testing.T) {
|
||||
c := newConfig().(*Config)
|
||||
c.Impersonation.Enabled = true
|
||||
c.IAM.Enabled, c.Tokenizer.Enabled, c.APIKeyConfig.Enabled = false, false, false
|
||||
c.IAM.Enabled, c.APIKeyConfig.Enabled = false, false
|
||||
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("impersonation alone is the local single-user mode and must validate: %v", err)
|
||||
|
||||
@@ -47,20 +47,6 @@ func NewIdentNResolver(ctx context.Context, providerSettings factory.ProviderSet
|
||||
identNs = append(identNs, identN)
|
||||
}
|
||||
|
||||
if identNConfig.Tokenizer.Enabled {
|
||||
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderTokenizer.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identNs = append(identNs, identN)
|
||||
}
|
||||
|
||||
if identNConfig.APIKeyConfig.Enabled {
|
||||
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderAPIKey.StringValue())
|
||||
if err != nil {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
package tokenizeridentn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/identn"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
tokenizer tokenizer.Tokenizer
|
||||
config identn.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
sfGroup *singleflight.Group
|
||||
}
|
||||
|
||||
func NewFactory(tokenizer tokenizer.Tokenizer) factory.ProviderFactory[identn.IdentN, identn.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderTokenizer.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
|
||||
return New(providerSettings, tokenizer, config)
|
||||
})
|
||||
}
|
||||
|
||||
func New(providerSettings factory.ProviderSettings, tokenizer tokenizer.Tokenizer, config identn.Config) (identn.IdentN, error) {
|
||||
return &provider{
|
||||
tokenizer: tokenizer,
|
||||
config: config,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/identn/tokenizeridentn"),
|
||||
sfGroup: &singleflight.Group{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Name() authtypes.IdentNProvider {
|
||||
return authtypes.IdentNProviderTokenizer
|
||||
}
|
||||
|
||||
func (provider *provider) Test(req *http.Request) bool {
|
||||
for _, header := range provider.config.Tokenizer.Headers {
|
||||
if req.Header.Get(header) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (provider *provider) Pre(req *http.Request) *http.Request {
|
||||
accessToken := provider.extractToken(req)
|
||||
if accessToken == "" {
|
||||
return req
|
||||
}
|
||||
|
||||
ctx := authtypes.NewContextWithAccessToken(req.Context(), accessToken)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
|
||||
ctx := req.Context()
|
||||
|
||||
accessToken, err := authtypes.AccessTokenFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.tokenizer.GetIdentity(ctx, accessToken)
|
||||
}
|
||||
|
||||
func (provider *provider) Post(ctx context.Context, _ *http.Request, _ authtypes.Claims) {
|
||||
if !provider.config.Tokenizer.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := authtypes.AccessTokenFromContext(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _, _ = provider.sfGroup.Do(accessToken, func() (any, error) {
|
||||
if err := provider.tokenizer.SetLastObservedAt(ctx, accessToken, time.Now()); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to set last observed at", errors.Attr(err))
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *provider) extractToken(req *http.Request) string {
|
||||
var value string
|
||||
for _, header := range provider.config.Tokenizer.Headers {
|
||||
if v := req.Header.Get(header); v != "" {
|
||||
value = v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
accessToken, ok := provider.parseBearerAuth(value)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
|
||||
func (provider *provider) parseBearerAuth(auth string) (string, bool) {
|
||||
const prefix = "Bearer "
|
||||
if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
|
||||
return "", false
|
||||
}
|
||||
return auth[len(prefix):], true
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package authdomain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/statsreporter"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// List all auth domains for an organization.
|
||||
ListByOrgID(context.Context, valuer.UUID) ([]*authtypes.AuthDomain, error)
|
||||
|
||||
// Get an auth domain by id.
|
||||
Get(context.Context, valuer.UUID) (*authtypes.AuthDomain, error)
|
||||
|
||||
// Get an auth domain by orgID and id.
|
||||
GetByOrgIDAndID(context.Context, valuer.UUID, valuer.UUID) (*authtypes.AuthDomain, error)
|
||||
|
||||
// Get an auth domain by name and orgID.
|
||||
GetByNameAndOrgID(context.Context, string, valuer.UUID) (*authtypes.AuthDomain, error)
|
||||
|
||||
// Create a new auth domain for an organization.
|
||||
Create(context.Context, *authtypes.AuthDomain) error
|
||||
|
||||
// Update an existing auth domain.
|
||||
Update(context.Context, *authtypes.AuthDomain) error
|
||||
|
||||
// Delete an existing auth domain by id.
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// Get the IDP info of the domain provided.
|
||||
GetAuthNProviderInfo(context.Context, *authtypes.AuthDomain) *authtypes.AuthNProviderInfo
|
||||
|
||||
statsreporter.StatsCollector
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
Create(http.ResponseWriter, *http.Request)
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type Getter interface {
|
||||
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package implauthdomain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type getter struct {
|
||||
store authtypes.AuthDomainStore
|
||||
}
|
||||
|
||||
func NewGetter(store authtypes.AuthDomainStore) authdomain.Getter {
|
||||
return &getter{store: store}
|
||||
}
|
||||
|
||||
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error {
|
||||
domains, err := getter.store.ListByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
referencedBy := make([]string, 0)
|
||||
for _, domain := range domains {
|
||||
for _, mappedRole := range domain.AuthDomainConfig().RoleMapping.RoleNames() {
|
||||
if mappedRole == roleName {
|
||||
referencedBy = append(referencedBy, domain.StorableAuthDomain().Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(referencedBy) > 0 {
|
||||
return errors.WithAdditionalf(
|
||||
errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleHasAuthDomainMappings, "role is referenced by an SSO role mapping, remove it before deleting"),
|
||||
"referenced by auth domain(s): %s", strings.Join(referencedBy, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package implauthdomain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/http/binding"
|
||||
"github.com/hanzoai/o11y/pkg/http/render"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module authdomain.Module
|
||||
}
|
||||
|
||||
func NewHandler(module authdomain.Module) authdomain.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) Create(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
body := new(authtypes.PostableAuthDomain)
|
||||
if err := binding.JSON.BindBody(req.Body, body); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
authDomain, err := authtypes.NewAuthDomainFromConfig(body.Name, &body.Config, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.Create(ctx, authDomain)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: authDomain.StorableAuthDomain().ID})
|
||||
}
|
||||
|
||||
func (handler *handler) Delete(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
domainId, err := valuer.NewUUID(coretypes.Param(req, "id"))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
err = handler.module.Delete(ctx, valuer.MustNewUUID(claims.OrgID), domainId)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
domainID, err := valuer.NewUUID(coretypes.Param(req, "id"))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
authDomain, err := handler.module.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), domainID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, authtypes.NewGettableAuthDomainFromAuthDomain(authDomain, handler.module.GetAuthNProviderInfo(ctx, authDomain)))
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
domains, err := handler.module.ListByOrgID(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
authDomains := make([]*authtypes.GettableAuthDomain, len(domains))
|
||||
for i, domain := range domains {
|
||||
authDomains[i] = authtypes.NewGettableAuthDomainFromAuthDomain(domain, handler.module.GetAuthNProviderInfo(ctx, domain))
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, authDomains)
|
||||
}
|
||||
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
domainID, err := valuer.NewUUID(coretypes.Param(r, "id"))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
body := new(authtypes.UpdatableAuthDomain)
|
||||
if err := binding.JSON.BindBody(r.Body, body); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
authDomain, err := handler.module.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), domainID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = authDomain.Update(&body.Config)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.module.Update(ctx, authDomain); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package implauthdomain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/http/routing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
const domainRoute = "/v1/o11y/domains/{id}"
|
||||
|
||||
// The per-domain routes address a domain by a PATH segment, so what has to hold
|
||||
// is that the segment the ROUTER matched is the domain the handler acts on.
|
||||
// Embedding the interface keeps the double to the methods these routes are
|
||||
// allowed to reach — every other call is a nil call, which states "this route
|
||||
// must not touch that".
|
||||
type recordingModule struct {
|
||||
authdomain.Module
|
||||
domainID valuer.UUID
|
||||
}
|
||||
|
||||
func (m *recordingModule) Delete(_ context.Context, _ valuer.UUID, domainID valuer.UUID) error {
|
||||
m.domainID = domainID
|
||||
return nil
|
||||
}
|
||||
|
||||
// A domain the store does not hold is a real answer for a real id, and it lets
|
||||
// the read be asserted without standing up an auth domain.
|
||||
func (m *recordingModule) GetByOrgIDAndID(_ context.Context, _ valuer.UUID, domainID valuer.UUID) (*authtypes.AuthDomain, error) {
|
||||
m.domainID = domainID
|
||||
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no such auth domain")
|
||||
}
|
||||
|
||||
// servedDomain drives one request through a REAL router registered at the
|
||||
// template the service registers, so the segment under assertion is one the
|
||||
// router matched — injecting it by hand would prove only that the injector and
|
||||
// the reader agree with each other.
|
||||
func servedDomain(t *testing.T, method, target, body string, pick func(authdomain.Handler) http.HandlerFunc) (*recordingModule, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
|
||||
module := new(recordingModule)
|
||||
router := routing.Serve(method, domainRoute, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pick(NewHandler(module))(w, r.WithContext(authtypes.NewContextWithClaims(r.Context(), authtypes.Claims{
|
||||
UserID: valuer.GenerateUUID().String(),
|
||||
OrgID: valuer.GenerateUUID().String(),
|
||||
})))
|
||||
}))
|
||||
|
||||
var reader io.Reader = http.NoBody
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, reader)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return module, rec
|
||||
}
|
||||
|
||||
func TestDeleteActsOnTheDomainTheRouterMatched(t *testing.T) {
|
||||
want := valuer.GenerateUUID()
|
||||
|
||||
module, rec := servedDomain(t, http.MethodDelete, "/v1/o11y/domains/"+want.String(), "", func(h authdomain.Handler) http.HandlerFunc { return h.Delete })
|
||||
|
||||
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
||||
assert.Equal(t, want, module.domainID)
|
||||
}
|
||||
|
||||
func TestGetLooksUpTheDomainTheRouterMatched(t *testing.T) {
|
||||
want := valuer.GenerateUUID()
|
||||
|
||||
module, _ := servedDomain(t, http.MethodGet, "/v1/o11y/domains/"+want.String(), "", func(h authdomain.Handler) http.HandlerFunc { return h.Get })
|
||||
|
||||
assert.Equal(t, want, module.domainID)
|
||||
}
|
||||
|
||||
func TestUpdateLooksUpTheDomainTheRouterMatched(t *testing.T) {
|
||||
want := valuer.GenerateUUID()
|
||||
|
||||
module, _ := servedDomain(t, http.MethodPut, "/v1/o11y/domains/"+want.String(), `{}`, func(h authdomain.Handler) http.HandlerFunc { return h.Update })
|
||||
|
||||
assert.Equal(t, want, module.domainID)
|
||||
}
|
||||
|
||||
// A segment that is not a uuid is refused before the store is reached — an
|
||||
// empty or malformed id must never arrive as a lookup for "whatever the zero
|
||||
// value matches".
|
||||
func TestDeleteRefusesANonUUIDSegment(t *testing.T) {
|
||||
module, rec := servedDomain(t, http.MethodDelete, "/v1/o11y/domains/not-a-uuid", "", func(h authdomain.Handler) http.HandlerFunc { return h.Delete })
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "invalid uuid not-a-uuid")
|
||||
assert.Equal(t, valuer.UUID{}, module.domainID, "a refused id must not reach the store")
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package implauthdomain
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/authn"
|
||||
"github.com/hanzoai/o11y/pkg/authz"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store authtypes.AuthDomainStore
|
||||
authNs map[authtypes.AuthNProvider]authn.AuthN
|
||||
authz authz.AuthZ
|
||||
}
|
||||
|
||||
func NewModule(store authtypes.AuthDomainStore, authNs map[authtypes.AuthNProvider]authn.AuthN, authz authz.AuthZ) authdomain.Module {
|
||||
return &module{store: store, authNs: authNs, authz: authz}
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, domain *authtypes.AuthDomain) error {
|
||||
if err := module.validateRoleMapping(ctx, domain); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.Create(ctx, domain)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, id valuer.UUID) (*authtypes.AuthDomain, error) {
|
||||
return module.store.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (module *module) GetAuthNProviderInfo(ctx context.Context, domain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
|
||||
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().AuthNProvider].(authn.CallbackAuthN); ok {
|
||||
return callbackAuthN.ProviderInfo(ctx, domain)
|
||||
}
|
||||
return &authtypes.AuthNProviderInfo{}
|
||||
}
|
||||
|
||||
func (module *module) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.AuthDomain, error) {
|
||||
return module.store.GetByOrgIDAndID(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) GetByNameAndOrgID(ctx context.Context, name string, orgID valuer.UUID) (*authtypes.AuthDomain, error) {
|
||||
return module.store.GetByNameAndOrgID(ctx, name, orgID)
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*authtypes.AuthDomain, error) {
|
||||
return module.store.ListByOrgID(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) Update(ctx context.Context, domain *authtypes.AuthDomain) error {
|
||||
if err := module.validateRoleMapping(ctx, domain); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.Update(ctx, domain)
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
domains, err := module.store.ListByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := make(map[string]any)
|
||||
|
||||
for _, domain := range domains {
|
||||
key := "authdomain." + domain.AuthDomainConfig().AuthNProvider.StringValue() + ".count"
|
||||
if value, ok := stats[key]; ok {
|
||||
stats[key] = value.(int64) + 1
|
||||
} else {
|
||||
stats[key] = int64(1)
|
||||
}
|
||||
}
|
||||
|
||||
stats["authdomain.count"] = len(domains)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (module *module) validateRoleMapping(ctx context.Context, domain *authtypes.AuthDomain) error {
|
||||
roleNames := domain.AuthDomainConfig().RoleMapping.RoleNames()
|
||||
if len(roleNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := module.authz.ListByOrgIDAndNames(ctx, domain.StorableAuthDomain().OrgID, roleNames)
|
||||
return err
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package implauthdomain
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) authtypes.AuthDomainStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, domain *authtypes.AuthDomain) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(domain.StorableAuthDomain()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, authtypes.ErrCodeAuthDomainAlreadyExists, "domain with name %s already exists", domain.StorableAuthDomain().Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
authDomain := new(authtypes.StorableAuthDomain)
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(authDomain).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, id valuer.UUID) (*authtypes.AuthDomain, error) {
|
||||
authDomain := new(authtypes.StorableAuthDomain)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(authDomain).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeAuthDomainNotFound, "auth domain with id %s does not exist", id)
|
||||
}
|
||||
|
||||
return authtypes.NewAuthDomainFromStorableAuthDomain(authDomain)
|
||||
}
|
||||
|
||||
func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.AuthDomain, error) {
|
||||
authDomain := new(authtypes.StorableAuthDomain)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(authDomain).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeAuthDomainNotFound, "auth domain with id %s does not exist", orgID, id)
|
||||
}
|
||||
|
||||
return authtypes.NewAuthDomainFromStorableAuthDomain(authDomain)
|
||||
}
|
||||
|
||||
func (store *store) GetByName(ctx context.Context, name string) (*authtypes.AuthDomain, error) {
|
||||
authDomain := new(authtypes.StorableAuthDomain)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(authDomain).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeAuthDomainNotFound, "auth domain with name %s does not exist", name)
|
||||
}
|
||||
|
||||
return authtypes.NewAuthDomainFromStorableAuthDomain(authDomain)
|
||||
}
|
||||
|
||||
func (store *store) GetByNameAndOrgID(ctx context.Context, name string, orgID valuer.UUID) (*authtypes.AuthDomain, error) {
|
||||
authDomain := new(authtypes.StorableAuthDomain)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(authDomain).
|
||||
Where("name = ?", name).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeAuthDomainNotFound, "auth domain with name %s and org id %s does not exist", name, orgID)
|
||||
}
|
||||
|
||||
return authtypes.NewAuthDomainFromStorableAuthDomain(authDomain)
|
||||
}
|
||||
|
||||
func (store *store) ListByOrgID(ctx context.Context, orgId valuer.UUID) ([]*authtypes.AuthDomain, error) {
|
||||
var storableAuthDomains []*authtypes.StorableAuthDomain
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storableAuthDomains).
|
||||
Where("org_id = ?", orgId).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authDomains := make([]*authtypes.AuthDomain, len(storableAuthDomains))
|
||||
for i, storableAuthDomain := range storableAuthDomains {
|
||||
authDomains[i], err = authtypes.NewAuthDomainFromStorableAuthDomain(storableAuthDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return authDomains, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, domain *authtypes.AuthDomain) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(domain.StorableAuthDomain()).
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -109,8 +109,13 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
diff := 0
|
||||
// Allow multiple deletions for API key requests; enforce for others
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderTokenizer {
|
||||
// Allow multiple widget deletions for MACHINE callers; hold a PERSON editing in
|
||||
// the console to one at a time. This used to name the identN provider — "is this
|
||||
// the tokenizer", i.e. o11y's own session token — which stopped naming a person
|
||||
// the moment o11y stopped minting sessions. The distinction it was reaching for
|
||||
// is the principal KIND, which every resolver already sets and no deletion can
|
||||
// take away.
|
||||
if claims.Principal == authtypes.PrincipalUser {
|
||||
diff = 1
|
||||
}
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
package implsession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/global"
|
||||
"github.com/hanzoai/o11y/pkg/http/binding"
|
||||
"github.com/hanzoai/o11y/pkg/http/render"
|
||||
"github.com/hanzoai/o11y/pkg/modules/session"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module session.Module
|
||||
globalConfig global.Config
|
||||
}
|
||||
|
||||
func NewHandler(module session.Module, globalConfig global.Config) session.Handler {
|
||||
return &handler{module: module, globalConfig: globalConfig}
|
||||
}
|
||||
|
||||
func (handler *handler) GetSessionContext(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
email, err := valuer.NewEmail(req.URL.Query().Get("email"))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
siteURL, err := url.Parse(req.URL.Query().Get("ref"))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
sessionContext, err := handler.module.GetSessionContext(ctx, email, siteURL)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, sessionContext)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateSessionByEmailPassword(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := new(authtypes.PostableEmailPasswordSession)
|
||||
if err := binding.JSON.BindBody(req.Body, body); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := handler.module.CreatePasswordAuthNSession(ctx, authtypes.AuthNProviderEmailPassword, body.Email, body.Password, body.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, authtypes.NewGettableTokenFromToken(token, handler.module.GetRotationInterval(ctx)))
|
||||
}
|
||||
|
||||
func (handler *handler) CreateSessionByGoogleCallback(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
values := req.URL.Query()
|
||||
|
||||
redirectURL, err := handler.module.CreateCallbackAuthNSession(ctx, authtypes.AuthNProviderGoogleAuth, values)
|
||||
if err != nil {
|
||||
http.Redirect(rw, req, handler.getRedirectURLFromErr(err), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(rw, req, redirectURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateSessionBySAMLCallback(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL, err := handler.module.CreateCallbackAuthNSession(ctx, authtypes.AuthNProviderSAML, req.Form)
|
||||
if err != nil {
|
||||
http.Redirect(rw, req, handler.getRedirectURLFromErr(err), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(rw, req, redirectURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateSessionByOIDCCallback(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
values := req.URL.Query()
|
||||
redirectURL, err := handler.module.CreateCallbackAuthNSession(ctx, authtypes.AuthNProviderOIDC, values)
|
||||
if err != nil {
|
||||
http.Redirect(rw, req, handler.getRedirectURLFromErr(err), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(rw, req, redirectURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (handler *handler) RotateSession(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := new(authtypes.PostableRotateToken)
|
||||
if err := binding.JSON.BindBody(req.Body, body); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := authtypes.AccessTokenFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := handler.module.RotateSession(ctx, accessToken, body.RefreshToken)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, authtypes.NewGettableTokenFromToken(token, handler.module.GetRotationInterval(ctx)))
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteSession(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
accessToken, err := authtypes.AccessTokenFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.DeleteSession(ctx, accessToken)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) getRedirectURLFromErr(err error) string {
|
||||
values := errors.AsURLValues(err)
|
||||
values.Add("callbackauthnerr", "true")
|
||||
|
||||
return (&url.URL{
|
||||
// When UI is being served on a prefix, we need to redirect to the login page on the prefix.
|
||||
Path: path.Join(handler.globalConfig.ExternalPath(), "/login"),
|
||||
RawQuery: values.Encode(),
|
||||
}).String()
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package implsession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/authn"
|
||||
"github.com/hanzoai/o11y/pkg/authz"
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain"
|
||||
"github.com/hanzoai/o11y/pkg/modules/organization"
|
||||
"github.com/hanzoai/o11y/pkg/modules/session"
|
||||
"github.com/hanzoai/o11y/pkg/modules/user"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
authNs map[authtypes.AuthNProvider]authn.AuthN
|
||||
userSetter user.Setter
|
||||
userGetter user.Getter
|
||||
authDomain authdomain.Module
|
||||
tokenizer tokenizer.Tokenizer
|
||||
orgGetter organization.Getter
|
||||
authz authz.AuthZ
|
||||
}
|
||||
|
||||
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ) session.Module {
|
||||
return &module{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/modules/session/implsession"),
|
||||
authNs: authNs,
|
||||
userSetter: userSetter,
|
||||
userGetter: userGetter,
|
||||
authDomain: authDomain,
|
||||
tokenizer: tokenizer,
|
||||
orgGetter: orgGetter,
|
||||
authz: authz,
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) GetSessionContext(ctx context.Context, email valuer.Email, siteURL *url.URL) (*authtypes.SessionContext, error) {
|
||||
context := authtypes.NewSessionContext()
|
||||
|
||||
orgs, err := module.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(orgs) == 0 {
|
||||
context.Exists = false
|
||||
return context, nil
|
||||
}
|
||||
|
||||
var orgIDs []valuer.UUID
|
||||
for _, org := range orgs {
|
||||
orgIDs = append(orgIDs, org.ID)
|
||||
}
|
||||
|
||||
users, err := module.userGetter.ListUsersByEmailAndOrgIDs(ctx, email, orgIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// filter out deleted users
|
||||
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.ErrIfDeleted() != nil })
|
||||
|
||||
// Since email is a valuer, we can be sure that it is a valid email and we can split it to get the domain name.
|
||||
name := strings.Split(email.String(), "@")[1]
|
||||
|
||||
if len(users) == 0 {
|
||||
context.Exists = false
|
||||
|
||||
for _, org := range orgs {
|
||||
orgContext, err := module.getOrgSessionContext(ctx, org, name, siteURL)
|
||||
if err != nil {
|
||||
// For some reason, there was an error in getting the org session context. Instead of failing the context call, we create a PasswordAuthNSupport for the org and add a warning.
|
||||
orgContext = authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword).AddWarning(err)
|
||||
}
|
||||
|
||||
context = context.AddOrgContext(orgContext)
|
||||
}
|
||||
|
||||
return context, nil
|
||||
}
|
||||
|
||||
context.Exists = true
|
||||
for _, user := range users {
|
||||
idx := slices.IndexFunc(orgs, func(org *types.Organization) bool {
|
||||
return org.ID == user.OrgID
|
||||
})
|
||||
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
org := orgs[idx]
|
||||
orgContext, err := module.getOrgSessionContext(ctx, org, name, siteURL)
|
||||
if err != nil {
|
||||
// For some reason, there was an error in getting the org session context. Instead of failing the context call, we create a PasswordAuthNSupport for the org and add a warning.
|
||||
orgContext = authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword).AddWarning(err)
|
||||
}
|
||||
|
||||
context = context.AddOrgContext(orgContext)
|
||||
}
|
||||
|
||||
return context, nil
|
||||
}
|
||||
|
||||
func (module *module) CreatePasswordAuthNSession(ctx context.Context, authNProvider authtypes.AuthNProvider, email valuer.Email, password string, orgID valuer.UUID) (*authtypes.Token, error) {
|
||||
passwordAuthN, err := getProvider[authn.PasswordAuthN](authNProvider, module.authNs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity, err := passwordAuthN.Authenticate(ctx, email.String(), password, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.tokenizer.CreateToken(ctx, identity, map[string]string{})
|
||||
}
|
||||
|
||||
func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvider authtypes.AuthNProvider, values url.Values) (string, error) {
|
||||
callbackAuthN, err := getProvider[authn.CallbackAuthN](authNProvider, module.authNs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
callbackIdentity, err := callbackAuthN.HandleCallback(ctx, values)
|
||||
if err != nil {
|
||||
module.settings.Logger().ErrorContext(ctx, "failed to handle callback", errors.Attr(err), slog.Any("authn_provider", authNProvider))
|
||||
return "", err
|
||||
}
|
||||
|
||||
authDomain, err := module.authDomain.GetByOrgIDAndID(ctx, callbackIdentity.OrgID, callbackIdentity.State.DomainID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
roleMapping := authDomain.AuthDomainConfig().RoleMapping
|
||||
|
||||
roleAttributeExists := false
|
||||
if roleMapping != nil && roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
|
||||
_, err := module.authz.GetByOrgIDAndName(ctx, callbackIdentity.OrgID, authtypes.NormalizeRoleName(callbackIdentity.Role))
|
||||
if err == nil {
|
||||
roleAttributeExists = true
|
||||
}
|
||||
}
|
||||
|
||||
roleNames := roleMapping.NewRolesFromCallbackIdentity(callbackIdentity, roleAttributeExists)
|
||||
|
||||
newUser, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, callbackIdentity.OrgID, types.UserStatusActive)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newUser, err = module.userSetter.GetOrCreateUser(ctx, newUser, user.WithRoleNames(roleNames))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := newUser.ErrIfRoot(); err != nil {
|
||||
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
|
||||
}
|
||||
|
||||
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewPrincipalUserIdentity(newUser.ID, newUser.OrgID, newUser.Email, authtypes.IdentNProviderTokenizer), map[string]string{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
redirectURL := &url.URL{
|
||||
Scheme: callbackIdentity.State.URL.Scheme,
|
||||
Host: callbackIdentity.State.URL.Host,
|
||||
Path: callbackIdentity.State.URL.Path,
|
||||
RawQuery: authtypes.NewURLValuesFromToken(token, module.GetRotationInterval(ctx)).Encode(),
|
||||
}
|
||||
|
||||
return redirectURL.String(), nil
|
||||
}
|
||||
|
||||
func (module *module) RotateSession(ctx context.Context, accessToken string, refreshToken string) (*authtypes.Token, error) {
|
||||
return module.tokenizer.RotateToken(ctx, accessToken, refreshToken)
|
||||
}
|
||||
|
||||
func (module *module) DeleteSession(ctx context.Context, accessToken string) error {
|
||||
return module.tokenizer.DeleteToken(ctx, accessToken)
|
||||
}
|
||||
|
||||
func (module *module) GetRotationInterval(context.Context) time.Duration {
|
||||
return module.tokenizer.Config().Rotation.Interval
|
||||
}
|
||||
|
||||
func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organization, name string, siteURL *url.URL) (*authtypes.OrgSessionContext, error) {
|
||||
authDomain, err := module.authDomain.GetByNameAndOrgID(ctx, name, org.ID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if authDomain == nil {
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().SSOEnabled {
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
|
||||
}
|
||||
|
||||
provider, err := getProvider[authn.CallbackAuthN](authDomain.AuthDomainConfig().AuthNProvider, module.authNs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loginURL, err := provider.LoginURL(ctx, siteURL, authDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.AuthDomainConfig().AuthNProvider, loginURL), nil
|
||||
}
|
||||
|
||||
func getProvider[T authn.AuthN](authNProvider authtypes.AuthNProvider, authNs map[authtypes.AuthNProvider]authn.AuthN) (T, error) {
|
||||
var provider T
|
||||
|
||||
provider, ok := authNs[authNProvider].(T)
|
||||
if !ok {
|
||||
return provider, errors.New(errors.TypeNotFound, errors.CodeNotFound, "authn provider not found")
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// Gets the session context for the user. The context contains information on what the user has to do in order to create a session.
|
||||
GetSessionContext(ctx context.Context, email valuer.Email, siteURL *url.URL) (*authtypes.SessionContext, error)
|
||||
|
||||
// Create a session for a user using password authn provider.
|
||||
CreatePasswordAuthNSession(ctx context.Context, authNProvider authtypes.AuthNProvider, email valuer.Email, password string, orgID valuer.UUID) (*authtypes.Token, error)
|
||||
|
||||
// Create a session for a user using callback authn providers.
|
||||
CreateCallbackAuthNSession(ctx context.Context, authNProvider authtypes.AuthNProvider, values url.Values) (string, error)
|
||||
|
||||
// Rotate a token.
|
||||
RotateSession(ctx context.Context, accessToken string, refreshToken string) (*authtypes.Token, error)
|
||||
|
||||
// Delete a session.
|
||||
DeleteSession(ctx context.Context, accessToken string) error
|
||||
|
||||
// Get the rotation interval for the session.
|
||||
GetRotationInterval(ctx context.Context) time.Duration
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
// Get the session context for the user.
|
||||
GetSessionContext(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Create a session for a user using email and password.
|
||||
CreateSessionByEmailPassword(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Create a session for a user using google callback.
|
||||
CreateSessionByGoogleCallback(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Create a session for a user using saml callback.
|
||||
CreateSessionBySAMLCallback(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Create a session for a user using oidc callback.
|
||||
CreateSessionByOIDCCallback(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Rotate a token.
|
||||
RotateSession(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Delete a session.
|
||||
DeleteSession(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
+13
-50
@@ -1,24 +1,26 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// Config is what remains once o11y stopped holding credentials: WHICH row the
|
||||
// local single-user mode stands in for, and nothing about how anyone proves who
|
||||
// they are. The password reset and invite token lifetimes went with the tokens.
|
||||
type Config struct {
|
||||
Password PasswordConfig `mapstructure:"password"`
|
||||
Root RootConfig `mapstructure:"root"`
|
||||
Root RootConfig `mapstructure:"root"`
|
||||
}
|
||||
|
||||
// RootConfig seeds the one row that impersonationidentn resolves to in local
|
||||
// single-user mode. It carries NO password: nothing authenticates against this
|
||||
// row — impersonation matches every request unconditionally, which is why
|
||||
// identn::impersonation refuses to boot alongside any real resolver.
|
||||
type RootConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Email valuer.Email `mapstructure:"email"`
|
||||
Password string `mapstructure:"password"`
|
||||
Org OrgConfig `mapstructure:"org"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Email valuer.Email `mapstructure:"email"`
|
||||
Org OrgConfig `mapstructure:"org"`
|
||||
}
|
||||
|
||||
type OrgConfig struct {
|
||||
@@ -26,35 +28,12 @@ type OrgConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
}
|
||||
|
||||
type PasswordConfig struct {
|
||||
Invite InviteConfig `mapstructure:"invite"`
|
||||
Reset ResetConfig `mapstructure:"reset"`
|
||||
}
|
||||
|
||||
type InviteConfig struct {
|
||||
MaxTokenLifetime time.Duration `mapstructure:"max_token_lifetime"`
|
||||
}
|
||||
|
||||
type ResetConfig struct {
|
||||
AllowSelf bool `mapstructure:"allow_self"`
|
||||
MaxTokenLifetime time.Duration `mapstructure:"max_token_lifetime"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("user"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Password: PasswordConfig{
|
||||
Reset: ResetConfig{
|
||||
AllowSelf: false,
|
||||
MaxTokenLifetime: 6 * time.Hour,
|
||||
},
|
||||
Invite: InviteConfig{
|
||||
MaxTokenLifetime: 48 * time.Hour,
|
||||
},
|
||||
},
|
||||
Root: RootConfig{
|
||||
Enabled: false,
|
||||
Org: OrgConfig{
|
||||
@@ -65,24 +44,8 @@ func newConfig() factory.Config {
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Password.Reset.MaxTokenLifetime <= 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::password::reset::max_token_lifetime must be positive")
|
||||
}
|
||||
|
||||
if c.Password.Invite.MaxTokenLifetime <= 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::password::invite::max_token_lifetime must be positive")
|
||||
}
|
||||
|
||||
if c.Root.Enabled {
|
||||
if c.Root.Email.IsZero() {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::email is required when root user is enabled")
|
||||
}
|
||||
if c.Root.Password == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::password is required when root user is enabled")
|
||||
}
|
||||
if !types.IsPasswordValid(c.Root.Password) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::password does not meet password requirements")
|
||||
}
|
||||
if c.Root.Enabled && c.Root.Email.IsZero() {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::email is required when root user is enabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -37,53 +37,6 @@ func (module *getter) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID)
|
||||
return rootUser, userRoles, nil
|
||||
}
|
||||
|
||||
func (module *getter) ListDeprecatedUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.DeprecatedUser, error) {
|
||||
users, err := module.store.ListUsersByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// filter root users if feature flag `hide_root_users` is true
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
hideRootUsers := module.flagger.BooleanOrEmpty(ctx, flagger.FeatureHideRootUser, evalCtx)
|
||||
|
||||
if hideRootUsers {
|
||||
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.IsRoot })
|
||||
}
|
||||
|
||||
userIDs := make([]valuer.UUID, len(users))
|
||||
for idx, user := range users {
|
||||
userIDs[idx] = user.ID
|
||||
}
|
||||
|
||||
userRoles, err := module.userRoleStore.ListUserRolesByOrgIDAndUserIDs(ctx, orgID, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build userID → role name mapping directly from the joined Role
|
||||
userIDToRoleNames := make(map[valuer.UUID][]string)
|
||||
for _, ur := range userRoles {
|
||||
if ur.Role != nil {
|
||||
userIDToRoleNames[ur.UserID] = append(userIDToRoleNames[ur.UserID], ur.Role.Name)
|
||||
}
|
||||
}
|
||||
|
||||
deprecatedUsers := make([]*types.DeprecatedUser, 0, len(users))
|
||||
for _, user := range users {
|
||||
roleNames := userIDToRoleNames[user.ID]
|
||||
|
||||
if len(roleNames) == 0 {
|
||||
return nil, errors.Newf(errors.TypeInternal, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found for user: %s", user.ID.String())
|
||||
}
|
||||
|
||||
role := authtypes.O11yManagedRoleToExistingLegacyRole[roleNames[0]]
|
||||
deprecatedUsers = append(deprecatedUsers, types.NewDeprecatedUserFromUserAndRole(user, role))
|
||||
}
|
||||
|
||||
return deprecatedUsers, nil
|
||||
}
|
||||
|
||||
func (module *getter) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error) {
|
||||
users, err := module.store.ListUsersByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
@@ -101,62 +54,10 @@ func (module *getter) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) (
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (module *getter) GetDeprecatedUserByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.DeprecatedUser, error) {
|
||||
user, err := module.store.GetByOrgIDAndID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userRoles, err := module.GetRolesByUserID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(userRoles) == 0 {
|
||||
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
|
||||
}
|
||||
|
||||
if userRoles[0].Role == nil {
|
||||
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
|
||||
}
|
||||
|
||||
role := authtypes.O11yManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
|
||||
|
||||
return types.NewDeprecatedUserFromUserAndRole(user, role), nil
|
||||
}
|
||||
|
||||
func (module *getter) GetUserByOrgIDAndID(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) (*types.User, error) {
|
||||
return module.store.GetByOrgIDAndID(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
func (module *getter) Get(ctx context.Context, id valuer.UUID) (*types.DeprecatedUser, error) {
|
||||
user, err := module.store.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userRoles, err := module.GetRolesByUserID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(userRoles) == 0 {
|
||||
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
|
||||
}
|
||||
|
||||
if userRoles[0].Role == nil {
|
||||
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
|
||||
}
|
||||
|
||||
role := authtypes.O11yManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
|
||||
|
||||
return types.NewDeprecatedUserFromUserAndRole(user, role), nil
|
||||
}
|
||||
|
||||
func (module *getter) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.User, error) {
|
||||
return module.store.ListUsersByEmailAndOrgIDs(ctx, email, orgIDs)
|
||||
}
|
||||
|
||||
func (module *getter) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
count, err := module.store.CountByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
@@ -175,70 +76,10 @@ func (module *getter) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (module *getter) GetFactorPasswordByUserID(ctx context.Context, userID valuer.UUID) (*types.FactorPassword, error) {
|
||||
factorPassword, err := module.store.GetPasswordByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return factorPassword, nil
|
||||
}
|
||||
|
||||
// GetNonDeletedUserByEmailAndOrgID restricts that only one non-deleted user email can exist for an org ID, if found more, it throws an error.
|
||||
func (module *getter) GetNonDeletedUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error) {
|
||||
existingUsers, err := module.store.GetNonDeletedUsersByEmailAndOrgID(ctx, email, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(existingUsers) > 1 {
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "Multiple non-deleted users found for email %s in org_id: %s", email.StringValue(), orgID.StringValue())
|
||||
}
|
||||
|
||||
if len(existingUsers) == 1 {
|
||||
return existingUsers[0], nil
|
||||
}
|
||||
|
||||
return nil, errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "No non-deleted user found with email %s in org_id: %s", email.StringValue(), orgID.StringValue())
|
||||
|
||||
}
|
||||
|
||||
func (module *getter) GetRolesByUserID(ctx context.Context, userID valuer.UUID) ([]*authtypes.UserRole, error) {
|
||||
userRoles, err := module.userRoleStore.GetUserRolesByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, ur := range userRoles {
|
||||
if ur.Role == nil {
|
||||
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
|
||||
}
|
||||
}
|
||||
|
||||
return userRoles, nil
|
||||
}
|
||||
|
||||
func (module *getter) GetResetPasswordTokenByOrgIDAndUserID(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) (*types.ResetPasswordToken, error) {
|
||||
return module.store.GetResetPasswordTokenByOrgIDAndUserID(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
func (module *getter) GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error) {
|
||||
return module.store.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
|
||||
}
|
||||
|
||||
func (module *getter) VerifyResetPasswordToken(ctx context.Context, token string) error {
|
||||
resetPasswordToken, err := module.store.GetResetPasswordToken(ctx, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resetPasswordToken.IsExpired() {
|
||||
return errors.New(errors.TypeUnauthenticated, types.ErrCodeResetPasswordTokenExpired, "reset password token has expired")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, _ string) error {
|
||||
users, err := module.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,195 +2,57 @@ package impluser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/http/binding"
|
||||
"github.com/hanzoai/o11y/pkg/http/render"
|
||||
root "github.com/hanzoai/o11y/pkg/modules/user"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
setter root.Setter
|
||||
getter root.Getter
|
||||
}
|
||||
|
||||
func NewHandler(setter root.Setter, getter root.Getter) root.Handler {
|
||||
return &handler{setter: setter, getter: getter}
|
||||
}
|
||||
|
||||
func (handler *handler) CreateUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(authtypes.PostableUser)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := types.NewUser(req.DisplayName, req.Email, valuer.MustNewUUID(claims.OrgID), types.UserStatusPendingInvite)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
roleIDs := make([]valuer.UUID, 0, len(req.UserRoles))
|
||||
for _, role := range req.UserRoles {
|
||||
roleIDs = append(roleIDs, role.ID)
|
||||
}
|
||||
|
||||
user, err = handler.setter.CreatePendingInviteUser(ctx, valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), req.FrontendBaseUrl, user, root.WithRoleIDs(roleIDs))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: user.ID})
|
||||
}
|
||||
|
||||
func (handler *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req types.PostableInvite
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
invites, err := handler.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &types.PostableBulkInviteRequest{
|
||||
Invites: []types.PostableInvite{req},
|
||||
})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, invites[0])
|
||||
}
|
||||
|
||||
func (handler *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req types.PostableBulkInviteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that the request contains users
|
||||
if len(req.Invites) == 0 {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "no invites provided for invitation"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetUserDeprecated(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.getter.GetDeprecatedUserByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (handler *handler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userID := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.getter.GetUserByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
userRoles, err := handler.getter.GetRolesByUserID(ctx, user.ID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
userWithRoles := &authtypes.UserWithRoles{
|
||||
User: user,
|
||||
UserRoles: userRoles,
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, userWithRoles)
|
||||
}
|
||||
|
||||
func (handler *handler) GetMyUserDeprecated(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.getter.GetDeprecatedUserByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, user)
|
||||
type handler struct{}
|
||||
|
||||
// NewHandler takes nothing, and that is the point. The user handler used to
|
||||
// hold a Setter and a Getter because it wrote passwords, minted invites, listed
|
||||
// members and edited roles. It performs none of those now, and the one route it
|
||||
// still answers reads no table at all.
|
||||
func NewHandler() root.Handler {
|
||||
return &handler{}
|
||||
}
|
||||
|
||||
// GetMyUser answers with the identity the EDGE asserted, projected into the
|
||||
// shape the console already reads.
|
||||
//
|
||||
// It used to SELECT the caller's row and 404 when it was missing — which made
|
||||
// o11y's own bookkeeping a precondition for rendering the app. A person the
|
||||
// gateway had authenticated, whose row had not been seated yet or had been
|
||||
// removed, got "user not found" on the very call the console uses to learn who
|
||||
// it is rendering for, and the whole console was unreachable. The identity was
|
||||
// never in doubt; the ROW was, and the row is not the identity.
|
||||
//
|
||||
// So it reads the claims. Same fields, one source, and no failure mode between
|
||||
// "the edge says you are signed in" and "the console shows you signed in":
|
||||
//
|
||||
// id the IAM subject — claims.UserID
|
||||
// orgId the IAM org (owner) — claims.OrgID
|
||||
// email the asserted address — claims.Email
|
||||
// displayName the same address, which is exactly what the seated row carried
|
||||
// (iamidentn builds it as NewUserWithID(id, email, email, …)), so
|
||||
// this is not a downgrade — it is the same value without the hop
|
||||
// isRoot false. Root is a local-single-user notion; an IAM session is
|
||||
// never root, and the row never set it either
|
||||
// status active. The edge does not forward a session for a user who is
|
||||
// not, and o11y has no status of its own to disagree with
|
||||
//
|
||||
// createdAt/updatedAt are left zero: they described the ROW's lifetime, never
|
||||
// the person's, and the console renders neither. Inventing a timestamp here
|
||||
// would be inventing a fact.
|
||||
//
|
||||
// No roles ride along. They used to, and nothing read them: the console resolves
|
||||
// what it may do through POST /v1/o11y/authz/check, which is the one way. A
|
||||
// second copy of the answer here could only ever disagree with it.
|
||||
func (handler *handler) GetMyUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -201,444 +63,29 @@ func (handler *handler) GetMyUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.getter.GetUserByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID))
|
||||
userID, err := valuer.NewUUID(claims.UserID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
userRoles, err := handler.getter.GetRolesByUserID(ctx, user.ID)
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
userWithRoles := &authtypes.UserWithRoles{
|
||||
User: user,
|
||||
UserRoles: userRoles,
|
||||
email, err := valuer.NewEmail(claims.Email)
|
||||
if err != nil {
|
||||
email = valuer.Email{}
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, userWithRoles)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateMyUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatableUser := new(types.UpdatableUser)
|
||||
if err := json.NewDecoder(r.Body).Decode(&updatableUser); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), updatableUser)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListUsersDeprecated(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := handler.getter.ListDeprecatedUsersByOrgID(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (handler *handler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := handler.getter.ListUsersByOrgID(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateUserDeprecated(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user := types.DeprecatedUser{User: &types.User{}}
|
||||
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := handler.setter.UpdateUserDeprecated(ctx, valuer.MustNewUUID(claims.OrgID), id, &user)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, updatedUser)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userID := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if userID == claims.UserID {
|
||||
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
|
||||
return
|
||||
}
|
||||
|
||||
updatableUser := new(types.UpdatableUser)
|
||||
if err := json.NewDecoder(r.Body).Decode(&updatableUser); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID), updatableUser)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.IdentityID()); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetResetPasswordTokenDeprecated(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.getter.GetDeprecatedUserByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := handler.setter.GetOrCreateResetPasswordToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, token)
|
||||
}
|
||||
|
||||
func (handler *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userID, err := valuer.NewUUID(coretypes.Param(r, "id"))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := handler.getter.GetResetPasswordTokenByOrgIDAndUserID(ctx, valuer.MustNewUUID(claims.OrgID), userID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, token)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateResetPasswordToken(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userID, err := valuer.NewUUID(coretypes.Param(r, "id"))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.getter.GetUserByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), userID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := handler.setter.GetOrCreateResetPasswordToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusCreated, token)
|
||||
}
|
||||
|
||||
func (handler *handler) VerifyResetPasswordToken(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := new(types.PostableVerifyResetPasswordToken)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := handler.getter.VerifyResetPasswordToken(ctx, req.Token)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := new(types.PostableResetPassword)
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := handler.setter.UpdatePasswordByResetPasswordToken(ctx, req.Token, req.Password)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req types.ChangePasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.setter.UpdatePassword(ctx, valuer.MustNewUUID(claims.UserID), req.OldPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := new(types.PostableForgotPassword)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := handler.setter.ForgotPassword(ctx, req.OrgID, req.Email, req.FrontendBaseURL)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetRolesByUserID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userID := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.getter.GetUserByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
userRoles, err := handler.getter.GetRolesByUserID(ctx, user.ID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
roles := make([]*authtypes.Role, len(userRoles))
|
||||
for idx, userRole := range userRoles {
|
||||
roles[idx] = userRole.Role
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, roles)
|
||||
}
|
||||
|
||||
func (handler *handler) SetRoleByUserID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userID := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if userID == claims.UserID {
|
||||
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
|
||||
return
|
||||
}
|
||||
|
||||
postableRole := new(types.PostableRole)
|
||||
if err := json.NewDecoder(r.Body).Decode(postableRole); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if postableRole.Name == "" {
|
||||
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "role name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.setter.AddUserRole(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID), postableRole.Name); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) RemoveUserRoleByRoleID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userID := coretypes.Param(r, "id")
|
||||
roleID := coretypes.Param(r, "roleId")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if userID == claims.UserID {
|
||||
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.setter.RemoveUserRole(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID), valuer.MustNewUUID(roleID)); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetUsersByRoleID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
roleID := coretypes.Param(r, "id")
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := handler.getter.GetUsersByOrgIDAndRoleID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(roleID))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, users)
|
||||
render.Success(w, http.StatusOK, &types.User{
|
||||
Identifiable: types.Identifiable{ID: userID},
|
||||
DisplayName: claims.Email,
|
||||
Email: email,
|
||||
OrgID: orgID,
|
||||
IsRoot: false,
|
||||
Status: types.UserStatusActive,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,137 +1,78 @@
|
||||
package impluser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/http/routing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
root "github.com/hanzoai/o11y/pkg/modules/user"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// The per-user routes address a user by a PATH segment, and one of them,
|
||||
// /users/{id}/roles/{roleId}, carries TWO. A handler that reads the wrong name
|
||||
// still compiles and still returns 2xx, it just acts on the wrong user — so
|
||||
// each test drives DISTINCT uuids through a REAL router and asserts which value
|
||||
// landed where.
|
||||
// GetMyUser ANSWERS FROM THE CLAIMS, and this is the test that says so.
|
||||
//
|
||||
// Embedding the interface keeps the doubles to the methods these routes are
|
||||
// allowed to reach — every other call is a nil call, which states "this route
|
||||
// must not touch that".
|
||||
type recordingSetter struct {
|
||||
root.Setter
|
||||
userID valuer.UUID
|
||||
roleID valuer.UUID
|
||||
}
|
||||
|
||||
func (s *recordingSetter) UpdateUser(_ context.Context, _ valuer.UUID, userID valuer.UUID, _ *types.UpdatableUser) (*types.User, error) {
|
||||
s.userID = userID
|
||||
return &types.User{}, nil
|
||||
}
|
||||
|
||||
func (s *recordingSetter) RemoveUserRole(_ context.Context, _ valuer.UUID, userID valuer.UUID, roleID valuer.UUID) error {
|
||||
s.userID, s.roleID = userID, roleID
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingGetter struct {
|
||||
root.Getter
|
||||
userID valuer.UUID
|
||||
}
|
||||
|
||||
func (g *recordingGetter) GetResetPasswordTokenByOrgIDAndUserID(_ context.Context, _ valuer.UUID, userID valuer.UUID) (*types.ResetPasswordToken, error) {
|
||||
g.userID = userID
|
||||
return &types.ResetPasswordToken{}, nil
|
||||
}
|
||||
|
||||
// servedUser drives one request through a REAL router registered at the
|
||||
// template the service registers, so the segments under assertion are ones the
|
||||
// router matched — injecting them by hand would prove only that the injector
|
||||
// and the reader agree with each other.
|
||||
func servedUser(t *testing.T, template, method, target, body string, claims authtypes.Claims, pick func(root.Handler) http.HandlerFunc) (*recordingSetter, *recordingGetter, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
|
||||
setter, getter := new(recordingSetter), new(recordingGetter)
|
||||
router := routing.Serve(method, template, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pick(NewHandler(setter, getter))(w, r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims)))
|
||||
}))
|
||||
|
||||
var reader io.Reader = http.NoBody
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
// The handler used to hold a Getter and SELECT the caller's row, which made
|
||||
// o11y's own bookkeeping a precondition for rendering the console: a person the
|
||||
// edge had authenticated, whose row was missing, got "user not found" on the one
|
||||
// call the console uses to learn who it is. NewHandler now takes NOTHING — there
|
||||
// is no store to reach — so the coupling is gone at the type level and this test
|
||||
// pins the values that come out.
|
||||
//
|
||||
// The mutation that must turn this red: read any field from anywhere but the
|
||||
// claims (a row, a config, a default) and the assertions below stop matching the
|
||||
// claims they were built from.
|
||||
func TestGetMyUserIsTheClaims(t *testing.T) {
|
||||
claims := authtypes.Claims{
|
||||
UserID: "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
|
||||
OrgID: "6ba7b811-9dad-11d1-80b4-00c04fd430c8",
|
||||
Email: "[email protected]",
|
||||
Principal: authtypes.PrincipalUser,
|
||||
}
|
||||
req := httptest.NewRequest(method, target, reader)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return setter, getter, rec
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/o11y/users/me", http.NoBody)
|
||||
NewHandler().GetMyUser(rec, req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims)))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s, want 200", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Status string `json:"status"`
|
||||
Data types.User `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, rec.Body)
|
||||
}
|
||||
|
||||
if got.Data.ID.StringValue() != claims.UserID {
|
||||
t.Errorf("id=%q, want the IAM subject %q", got.Data.ID.StringValue(), claims.UserID)
|
||||
}
|
||||
if got.Data.OrgID.StringValue() != claims.OrgID {
|
||||
t.Errorf("orgId=%q, want the asserted org %q", got.Data.OrgID.StringValue(), claims.OrgID)
|
||||
}
|
||||
if got.Data.Email.StringValue() != claims.Email {
|
||||
t.Errorf("email=%q, want the asserted address %q", got.Data.Email.StringValue(), claims.Email)
|
||||
}
|
||||
if got.Data.DisplayName != claims.Email {
|
||||
t.Errorf("displayName=%q, want the asserted address %q — the seated row carried the same value", got.Data.DisplayName, claims.Email)
|
||||
}
|
||||
if got.Data.IsRoot {
|
||||
t.Error("isRoot=true — an IAM session is never the local root user")
|
||||
}
|
||||
if got.Data.Status != types.UserStatusActive {
|
||||
t.Errorf("status=%q, want active", got.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func someoneElse(t *testing.T) authtypes.Claims {
|
||||
t.Helper()
|
||||
return authtypes.Claims{UserID: valuer.GenerateUUID().String(), OrgID: valuer.GenerateUUID().String()}
|
||||
}
|
||||
|
||||
func TestUpdateUserActsOnTheUserTheRouterMatched(t *testing.T) {
|
||||
want := valuer.GenerateUUID()
|
||||
|
||||
setter, _, rec := servedUser(t, "/v1/o11y/users/{id}", http.MethodPut, "/v1/o11y/users/"+want.String(), `{"displayName":"renamed"}`, someoneElse(t), func(h root.Handler) http.HandlerFunc { return h.UpdateUser })
|
||||
|
||||
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
||||
assert.Equal(t, want, setter.userID)
|
||||
}
|
||||
|
||||
// The self-modification guard compares the PATH segment against the caller's
|
||||
// own id, so the guard only holds while that segment is read correctly: read
|
||||
// the wrong name and it compares "" to the caller and lets the edit through.
|
||||
func TestUpdateUserRefusesTheCallerEditingSelfByPath(t *testing.T) {
|
||||
self := valuer.GenerateUUID()
|
||||
claims := authtypes.Claims{UserID: self.String(), OrgID: valuer.GenerateUUID().String()}
|
||||
|
||||
setter, _, rec := servedUser(t, "/v1/o11y/users/{id}", http.MethodPut, "/v1/o11y/users/"+self.String(), `{"displayName":"renamed"}`, claims, func(h root.Handler) http.HandlerFunc { return h.UpdateUser })
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "users cannot call this api on self")
|
||||
assert.Equal(t, valuer.UUID{}, setter.userID, "a refused edit must not reach the store")
|
||||
}
|
||||
|
||||
// {id} and {roleId} are two segments of one path: the user, and the role they
|
||||
// lose. Swapping them would revoke by the wrong identifier.
|
||||
func TestRemoveUserRoleSeparatesTheUserFromTheRole(t *testing.T) {
|
||||
user, role := valuer.GenerateUUID(), valuer.GenerateUUID()
|
||||
|
||||
setter, _, rec := servedUser(t, "/v1/o11y/users/{id}/roles/{roleId}", http.MethodDelete, "/v1/o11y/users/"+user.String()+"/roles/"+role.String(), "", someoneElse(t), func(h root.Handler) http.HandlerFunc { return h.RemoveUserRoleByRoleID })
|
||||
|
||||
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
||||
assert.Equal(t, user, setter.userID)
|
||||
assert.Equal(t, role, setter.roleID)
|
||||
}
|
||||
|
||||
func TestGetResetPasswordTokenReadsTheUserTheRouterMatched(t *testing.T) {
|
||||
want := valuer.GenerateUUID()
|
||||
|
||||
_, getter, rec := servedUser(t, "/v1/o11y/users/{id}/reset_password_tokens", http.MethodGet, "/v1/o11y/users/"+want.String()+"/reset_password_tokens", "", someoneElse(t), func(h root.Handler) http.HandlerFunc { return h.GetResetPasswordToken })
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
assert.Equal(t, want, getter.userID)
|
||||
}
|
||||
|
||||
// A segment that is not a uuid is refused before the store is reached — an
|
||||
// empty or malformed id must never arrive as a lookup for "whatever the zero
|
||||
// value matches".
|
||||
func TestGetResetPasswordTokenRefusesANonUUIDSegment(t *testing.T) {
|
||||
_, getter, rec := servedUser(t, "/v1/o11y/users/{id}/reset_password_tokens", http.MethodGet, "/v1/o11y/users/not-a-uuid/reset_password_tokens", "", someoneElse(t), func(h root.Handler) http.HandlerFunc { return h.GetResetPasswordToken })
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "invalid uuid not-a-uuid")
|
||||
assert.Equal(t, valuer.UUID{}, getter.userID, "a refused id must not reach the store")
|
||||
// No claims, no answer. The route is OpenAccess — "is anyone authenticated" —
|
||||
// so the refusal has to come from here, and it has to be a refusal.
|
||||
func TestGetMyUserWithoutClaimsIsUnauthenticated(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
NewHandler().GetMyUser(rec, httptest.NewRequest(http.MethodGet, "/v1/o11y/users/me", http.NoBody))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d body=%s, want 401", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,26 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/modules/user"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// The root-user service seeds the ONE row that local single-user mode stands in
|
||||
// for: identn::impersonation resolves every request to it, which is the whole of
|
||||
// that mode and the reason it refuses to boot beside any real resolver.
|
||||
//
|
||||
// It used to reconcile a PASSWORD onto that row on every start — create it,
|
||||
// compare it, rotate it — from `user::root::password` in the process
|
||||
// environment. Nothing reads a password any more, so there is nothing to
|
||||
// reconcile: the row's existence IS the whole fact. What is left is
|
||||
// resolve-or-create the org with its managed roles, and resolve-or-create the
|
||||
// root row with the admin role, which is the same shape iamidentn uses when a
|
||||
// real IAM subject arrives.
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
store types.UserStore
|
||||
getter user.Getter
|
||||
setter user.Setter
|
||||
orgGetter organization.Getter
|
||||
orgSetter organization.Setter
|
||||
authz authz.AuthZ
|
||||
config user.RootConfig
|
||||
stopC chan struct{}
|
||||
@@ -30,18 +40,18 @@ type service struct {
|
||||
func NewService(
|
||||
providerSettings factory.ProviderSettings,
|
||||
store types.UserStore,
|
||||
getter user.Getter,
|
||||
setter user.Setter,
|
||||
orgGetter organization.Getter,
|
||||
orgSetter organization.Setter,
|
||||
authz authz.AuthZ,
|
||||
config user.RootConfig,
|
||||
) user.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "go.o11y.io/pkg/modules/user"),
|
||||
store: store,
|
||||
getter: getter,
|
||||
setter: setter,
|
||||
orgGetter: orgGetter,
|
||||
orgSetter: orgSetter,
|
||||
authz: authz,
|
||||
config: config,
|
||||
stopC: make(chan struct{}),
|
||||
@@ -79,31 +89,30 @@ func (s *service) Start(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Healthy() <-chan struct{} {
|
||||
return s.healthyC
|
||||
}
|
||||
func (s *service) Healthy() <-chan struct{} { return s.healthyC }
|
||||
|
||||
func (s *service) Stop(ctx context.Context) error {
|
||||
close(s.stopC)
|
||||
return nil
|
||||
}
|
||||
func (s *service) Stop(_ context.Context) error { close(s.stopC); return nil }
|
||||
|
||||
func (s *service) reconcile(ctx context.Context) error {
|
||||
org, resolvedByName, err := s.orgGetter.GetByIDOrName(ctx, s.config.Org.ID, s.config.Org.Name)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err // something really went wrong
|
||||
}
|
||||
|
||||
if s.config.Org.ID.IsZero() {
|
||||
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
|
||||
_, err := s.setter.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
|
||||
return err
|
||||
}
|
||||
|
||||
newOrg := types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name)
|
||||
_, err = s.setter.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
|
||||
return err
|
||||
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
|
||||
if !s.config.Org.ID.IsZero() {
|
||||
newOrg = types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name)
|
||||
}
|
||||
|
||||
managedRoles := authtypes.NewManagedRoles(newOrg.ID)
|
||||
if err := s.orgSetter.Create(ctx, newOrg, func(ctx context.Context, id valuer.UUID) error {
|
||||
return s.authz.CreateManagedRoles(ctx, id, managedRoles)
|
||||
}); err != nil && !errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.ensureRootUser(ctx, newOrg.ID)
|
||||
}
|
||||
|
||||
if !s.config.Org.ID.IsZero() && resolvedByName {
|
||||
@@ -111,125 +120,29 @@ func (s *service) reconcile(ctx context.Context) error {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, org.ID.StringValue(), s.config.Org.ID.StringValue())
|
||||
}
|
||||
|
||||
return s.reconcileRootUser(ctx, org.ID)
|
||||
return s.ensureRootUser(ctx, org.ID)
|
||||
}
|
||||
|
||||
func (s *service) reconcileRootUser(ctx context.Context, orgID valuer.UUID) error {
|
||||
existingStorableRoot, err := s.store.GetRootUserByOrgID(ctx, orgID)
|
||||
// ensureRootUser creates the root row on first sight, with the org's admin role.
|
||||
// A row that is already there is already the whole fact — there is nothing left
|
||||
// to bring into line now that it carries no credential.
|
||||
func (s *service) ensureRootUser(ctx context.Context, orgID valuer.UUID) error {
|
||||
existing, err := s.store.GetRootUserByOrgID(ctx, orgID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingStorableRoot == nil {
|
||||
return s.createOrPromoteRootUser(ctx, orgID)
|
||||
}
|
||||
|
||||
return s.updateExistingRootUser(ctx, orgID, existingStorableRoot)
|
||||
}
|
||||
|
||||
func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID) error {
|
||||
existingUser, err := s.getter.GetNonDeletedUserByEmailAndOrgID(ctx, s.config.Email, orgID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingUser != nil {
|
||||
userRoles, err := s.getter.GetRolesByUserID(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingUserRoleNames := make([]string, len(userRoles))
|
||||
for idx, userRole := range userRoles {
|
||||
existingUserRoleNames[idx] = userRole.Role.Name
|
||||
}
|
||||
|
||||
// idempotent - safe to retry can't put this in a txn
|
||||
if err := s.authz.ModifyGrant(ctx,
|
||||
orgID,
|
||||
existingUserRoleNames,
|
||||
[]string{authtypes.O11yAdminRoleName},
|
||||
authtypes.MustNewSubject(coretypes.NewResourceUser(), existingUser.ID.StringValue(), orgID, nil),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingUser.PromoteToRoot()
|
||||
|
||||
err = s.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := s.setter.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update user_role entries
|
||||
if err := s.setter.UpdateUserRoles(
|
||||
ctx,
|
||||
existingUser.OrgID,
|
||||
existingUser.ID,
|
||||
[]string{authtypes.O11yAdminRoleName},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set password
|
||||
return s.setPassword(ctx, existingUser.ID)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create new root user
|
||||
newUser, err := types.NewRootUser(s.config.Email.String(), s.config.Email, orgID)
|
||||
rootUser, err := types.NewRootUser(s.config.Email.String(), s.config.Email, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
factorPassword, err := types.NewFactorPassword(s.config.Password, newUser.ID.StringValue())
|
||||
if err != nil {
|
||||
if err := s.setter.CreateUser(ctx, rootUser, user.WithRoleNames([]string{authtypes.O11yAdminRoleName})); err != nil && !errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.setter.CreateUser(ctx, newUser, user.WithFactorPassword(factorPassword), user.WithRoleNames([]string{authtypes.O11yAdminRoleName}))
|
||||
}
|
||||
|
||||
func (s *service) updateExistingRootUser(ctx context.Context, orgID valuer.UUID, existingRoot *types.User) error {
|
||||
existingRoot.PromoteToRoot()
|
||||
|
||||
if existingRoot.Email != s.config.Email {
|
||||
existingRoot.UpdateEmail(s.config.Email)
|
||||
if err := s.setter.UpdateAnyUser(ctx, orgID, existingRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.setPassword(ctx, existingRoot.ID)
|
||||
}
|
||||
|
||||
func (s *service) setPassword(ctx context.Context, userID valuer.UUID) error {
|
||||
password, err := s.store.GetPasswordByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
factorPassword, err := types.NewFactorPassword(s.config.Password, userID.StringValue())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.CreatePassword(ctx, factorPassword)
|
||||
}
|
||||
|
||||
if !password.Equals(s.config.Password) {
|
||||
if err := password.Update(s.config.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.UpdatePassword(ctx, password)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,10 @@ package impluser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/preferencetypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -24,20 +19,6 @@ func NewStore(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings) typ
|
||||
return &store{sqlstore: sqlstore, settings: settings}
|
||||
}
|
||||
|
||||
func (store *store) CreatePassword(ctx context.Context, password *types.FactorPassword) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(password).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password for user %s already exists", password.UserID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateUser(ctx context.Context, user *types.User) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
@@ -51,23 +32,6 @@ func (store *store) CreateUser(ctx context.Context, user *types.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetUser(ctx context.Context, id valuer.UUID) (*types.User, error) {
|
||||
user := new(types.User)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(user).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with id %s does not exist", id)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.User, error) {
|
||||
user := new(types.User)
|
||||
|
||||
@@ -86,45 +50,6 @@ func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id v
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *store) GetNonDeletedUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*types.User, error) {
|
||||
var users []*types.User
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&users).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("email = ?", email).
|
||||
Where("status != ?", types.UserStatusDeleted).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(user).
|
||||
Column("display_name").
|
||||
Column("email").
|
||||
Column("is_root").
|
||||
Column("updated_at").
|
||||
Column("status").
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", user.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user does not exist in org: %s", orgID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error) {
|
||||
users := []*types.User{}
|
||||
|
||||
@@ -142,285 +67,6 @@ func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteUser(ctx context.Context, orgID string, id string) error {
|
||||
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
// get the password id
|
||||
|
||||
var password types.FactorPassword
|
||||
err = tx.NewSelect().
|
||||
Model(&password).
|
||||
Where("user_id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete password")
|
||||
}
|
||||
|
||||
// delete reset password request
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(types.ResetPasswordToken)).
|
||||
Where("password_id = ?", password.ID.String()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete reset password request")
|
||||
}
|
||||
|
||||
// delete factor password
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(types.FactorPassword)).
|
||||
Where("user_id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password")
|
||||
}
|
||||
|
||||
// delete user_preference
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(preferencetypes.StorableUserPreference)).
|
||||
Where("user_id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user preferences")
|
||||
}
|
||||
|
||||
// delete tokens
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(authtypes.StorableToken)).
|
||||
Where("user_id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete tokens")
|
||||
}
|
||||
|
||||
// delete user
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(types.User)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user")
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string) error {
|
||||
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
// get the password id
|
||||
|
||||
var password types.FactorPassword
|
||||
err = tx.NewSelect().
|
||||
Model(&password).
|
||||
Where("user_id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete password")
|
||||
}
|
||||
|
||||
// delete reset password request
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(types.ResetPasswordToken)).
|
||||
Where("password_id = ?", password.ID.String()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete reset password request")
|
||||
}
|
||||
|
||||
// delete factor password
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(types.FactorPassword)).
|
||||
Where("user_id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password")
|
||||
}
|
||||
|
||||
// delete user_preference
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(preferencetypes.StorableUserPreference)).
|
||||
Where("user_id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user preferences")
|
||||
}
|
||||
|
||||
// delete tokens
|
||||
_, err = tx.NewDelete().
|
||||
Model(new(authtypes.StorableToken)).
|
||||
Where("user_id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete tokens")
|
||||
}
|
||||
|
||||
// soft delete user
|
||||
now := time.Now()
|
||||
_, err = tx.NewUpdate().
|
||||
Model(new(types.User)).
|
||||
Set("status = ?", types.UserStatusDeleted).
|
||||
Set("updated_at = ?", now).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user")
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateResetPasswordToken(ctx context.Context, resetPasswordToken *types.ResetPasswordToken) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(resetPasswordToken).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrResetPasswordTokenAlreadyExists, "reset password token for password %s already exists", resetPasswordToken.PasswordID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetPassword(ctx context.Context, id valuer.UUID) (*types.FactorPassword, error) {
|
||||
password := new(types.FactorPassword)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(password).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with id: %s does not exist", id)
|
||||
}
|
||||
|
||||
return password, nil
|
||||
}
|
||||
|
||||
func (store *store) GetPasswordByUserID(ctx context.Context, userID valuer.UUID) (*types.FactorPassword, error) {
|
||||
password := new(types.FactorPassword)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(password).
|
||||
Where("user_id = ?", userID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password for user %s does not exist", userID)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
func (store *store) GetResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) (*types.ResetPasswordToken, error) {
|
||||
resetPasswordToken := new(types.ResetPasswordToken)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(resetPasswordToken).
|
||||
Where("password_id = ?", passwordID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token for password %s does not exist", passwordID)
|
||||
}
|
||||
|
||||
return resetPasswordToken, nil
|
||||
}
|
||||
|
||||
func (store *store) GetResetPasswordTokenByOrgIDAndUserID(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) (*types.ResetPasswordToken, error) {
|
||||
resetPasswordToken := new(types.ResetPasswordToken)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(resetPasswordToken).
|
||||
Join("JOIN factor_password ON factor_password.id = reset_password_token.password_id").
|
||||
Join("JOIN users ON users.id = factor_password.user_id").
|
||||
Where("factor_password.user_id = ?", userID).
|
||||
Where("users.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token for user %s does not exist", userID)
|
||||
}
|
||||
|
||||
return resetPasswordToken, nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) error {
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().
|
||||
Model(&types.ResetPasswordToken{}).
|
||||
Where("password_id = ?", passwordID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete reset password token")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetResetPasswordToken(ctx context.Context, token string) (*types.ResetPasswordToken, error) {
|
||||
resetPasswordRequest := new(types.ResetPasswordToken)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(resetPasswordRequest).
|
||||
Where("token = ?", token).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token does not exist")
|
||||
}
|
||||
|
||||
return resetPasswordRequest, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdatePassword(ctx context.Context, factorPassword *types.FactorPassword) error {
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(factorPassword).
|
||||
Where("user_id = ?", factorPassword.UserID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password for user %s does not exist", factorPassword.UserID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
user := new(types.User)
|
||||
|
||||
@@ -490,61 +136,6 @@ func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.User, error) {
|
||||
users := []*types.User{}
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&users).
|
||||
Where("email = ?", email).
|
||||
Where("org_id IN (?)", bun.In(orgIDs)).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (store *store) GetUserByResetPasswordToken(ctx context.Context, token string) (*types.User, error) {
|
||||
user := new(types.User)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(user).
|
||||
Join(`JOIN factor_password ON factor_password.user_id = "user".id`).
|
||||
Join("JOIN reset_password_token ON reset_password_token.password_id = factor_password.id").
|
||||
Where("reset_password_token.token = ?", token).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user not found for reset password token")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, emails []string, statuses []string) ([]*types.User, error) {
|
||||
users := []*types.User{}
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&users).
|
||||
Where("email IN (?)", bun.In(emails)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("status in (?)", bun.In(statuses)).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (store *store) GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error) {
|
||||
users := []*types.User{}
|
||||
|
||||
|
||||
@@ -1,69 +1,25 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// The only thing a caller still says about a user it creates is which roles the
|
||||
// user holds. WithFactorPassword is gone with the password store, WithRoleIDs
|
||||
// with the member-administration API, and the authenticate options with the
|
||||
// sessions o11y no longer mints.
|
||||
type createUserOptions struct {
|
||||
FactorPassword *types.FactorPassword
|
||||
RoleNames []string
|
||||
RoleIDs []valuer.UUID
|
||||
RoleNames []string
|
||||
}
|
||||
|
||||
type CreateUserOption func(*createUserOptions)
|
||||
|
||||
func WithFactorPassword(factorPassword *types.FactorPassword) CreateUserOption {
|
||||
return func(o *createUserOptions) {
|
||||
o.FactorPassword = factorPassword
|
||||
}
|
||||
}
|
||||
|
||||
func WithRoleNames(roleNames []string) CreateUserOption {
|
||||
return func(o *createUserOptions) {
|
||||
o.RoleNames = roleNames
|
||||
}
|
||||
}
|
||||
|
||||
func WithRoleIDs(roleIDs []valuer.UUID) CreateUserOption {
|
||||
return func(o *createUserOptions) {
|
||||
o.RoleIDs = roleIDs
|
||||
}
|
||||
}
|
||||
|
||||
func NewCreateUserOptions(opts ...CreateUserOption) *createUserOptions {
|
||||
o := &createUserOptions{
|
||||
FactorPassword: nil,
|
||||
RoleNames: nil,
|
||||
}
|
||||
|
||||
o := &createUserOptions{RoleNames: nil}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
type authenticateOptions struct {
|
||||
OrgID valuer.UUID
|
||||
}
|
||||
|
||||
type AuthenticateOption func(*authenticateOptions)
|
||||
|
||||
func WithOrgID(orgID valuer.UUID) AuthenticateOption {
|
||||
return func(o *authenticateOptions) {
|
||||
o.OrgID = orgID
|
||||
}
|
||||
}
|
||||
|
||||
func NewAuthenticateOptions(opts ...AuthenticateOption) *authenticateOptions {
|
||||
o := &authenticateOptions{
|
||||
OrgID: valuer.UUID{},
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
+29
-104
@@ -10,129 +10,54 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// The user module is a PROJECTION of a Hanzo IAM identity, not an identity.
|
||||
//
|
||||
// Nothing here authenticates. There is no password, no invite, no reset token,
|
||||
// no session — those were deleted with pkg/authn, pkg/tokenizer and
|
||||
// pkg/modules/session, because o11y asserting who you are is a second answer to
|
||||
// a question IAM already answered, and two answers is one too many.
|
||||
//
|
||||
// What remains exists for one reason: o11y's OWN rows — a dashboard's author, a
|
||||
// saved view, a preference, a role grant — are keyed by user id, and a foreign
|
||||
// key needs something to point at. iamidentn writes that row on first sight of
|
||||
// an IAM subject, keyed by IAM's own subject id. Everything below is either
|
||||
// that write, a read of it by o11y's own bookkeeping (stats, the role-deletion
|
||||
// guard), or the one read the console makes of ITSELF — and that one is served
|
||||
// from the CLAIMS, not from the row.
|
||||
type Setter interface {
|
||||
// Creates the organization and the first user of that organization.
|
||||
CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email valuer.Email, password string) (*types.User, error)
|
||||
|
||||
// Creates a user and sends an analytics event.
|
||||
// CreateUser writes the projection row and grants it the roles named in
|
||||
// opts. The ONLY caller is identn/iamidentn on first sight of an IAM subject;
|
||||
// the grant and the durable user_role entries happen in this one call, which
|
||||
// is what localauthz rehydrates from after a restart.
|
||||
CreateUser(ctx context.Context, user *types.User, opts ...CreateUserOption) error
|
||||
|
||||
// Get or create a user. If a user with the same email and orgID already exists, it returns the existing user.
|
||||
GetOrCreateUser(ctx context.Context, user *types.User, opts ...CreateUserOption) (*types.User, error)
|
||||
|
||||
// Get or Create a reset password token for a user. If the password does not exist, a new one is randomly generated and inserted. The function
|
||||
// is idempotent and can be called multiple times.
|
||||
GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error)
|
||||
|
||||
// Updates password of a user using a reset password token. It also deletes all reset password tokens for the user.
|
||||
// This is used to reset the password of a user when they forget their password.
|
||||
UpdatePasswordByResetPasswordToken(ctx context.Context, token string, password string) error
|
||||
|
||||
// Updates password of user to the new password. It also deletes all reset password tokens for the user.
|
||||
UpdatePassword(ctx context.Context, userID valuer.UUID, oldPassword string, password string) error
|
||||
|
||||
// Initiate forgot password flow for a user
|
||||
ForgotPassword(ctx context.Context, orgID valuer.UUID, email valuer.Email, frontendBaseURL string) error
|
||||
|
||||
UpdateUserDeprecated(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error)
|
||||
UpdateUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, updatable *types.UpdatableUser) (*types.User, error)
|
||||
|
||||
// UpdateAnyUser updates a user and persists the changes to the database along with the analytics and identity deletion.
|
||||
UpdateAnyUserDeprecated(ctx context.Context, orgID valuer.UUID, deprecateUser *types.DeprecatedUser) error
|
||||
UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.User) error
|
||||
DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error
|
||||
|
||||
// invite
|
||||
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
|
||||
|
||||
// Creates a pending invite user with the roles given via opts and emails them the invite link.
|
||||
CreatePendingInviteUser(ctx context.Context, identityID valuer.UUID, identityEmail valuer.Email, frontendBaseURL string, user *types.User, opts ...CreateUserOption) (*types.User, error)
|
||||
|
||||
// Roles
|
||||
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
|
||||
AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) error
|
||||
RemoveUserRole(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) error
|
||||
|
||||
statsreporter.StatsCollector
|
||||
}
|
||||
|
||||
type Getter interface {
|
||||
// Get root user by org id.
|
||||
// GetRootUserByOrgID resolves the org's root row — the single principal the
|
||||
// impersonation identN resolver stands in for in local single-user mode.
|
||||
GetRootUserByOrgID(context.Context, valuer.UUID) (*types.User, []*authtypes.UserRole, error)
|
||||
|
||||
// Get gets the users based on the given org id
|
||||
ListDeprecatedUsersByOrgID(context.Context, valuer.UUID) ([]*types.DeprecatedUser, error)
|
||||
ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error)
|
||||
|
||||
// Get deprecated user object by orgID and id.
|
||||
GetDeprecatedUserByOrgIDAndID(context.Context, valuer.UUID, valuer.UUID) (*types.DeprecatedUser, error)
|
||||
// GetUserByOrgIDAndID answers whether iamidentn has already seated this
|
||||
// subject, so the founding write happens once.
|
||||
GetUserByOrgIDAndID(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) (*types.User, error)
|
||||
|
||||
// Get user by id.
|
||||
Get(context.Context, valuer.UUID) (*types.DeprecatedUser, error)
|
||||
|
||||
// List users by email and org ids.
|
||||
ListUsersByEmailAndOrgIDs(context.Context, valuer.Email, []valuer.UUID) ([]*types.User, error)
|
||||
|
||||
// Count users by org id.
|
||||
// ListUsersByOrgID, CountByOrgID and CountByOrgIDAndStatuses feed the stats
|
||||
// reporter — how many members a tenant has, not who they are.
|
||||
ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error)
|
||||
CountByOrgID(context.Context, valuer.UUID) (int64, error)
|
||||
|
||||
// Count of users by org id and grouped by status.
|
||||
CountByOrgIDAndStatuses(context.Context, valuer.UUID, []string) (map[valuer.String]int64, error)
|
||||
|
||||
// Get factor password by user id.
|
||||
GetFactorPasswordByUserID(context.Context, valuer.UUID) (*types.FactorPassword, error)
|
||||
|
||||
// Get reset password token by org id and user id.
|
||||
GetResetPasswordTokenByOrgIDAndUserID(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) (*types.ResetPasswordToken, error)
|
||||
|
||||
// Gets single Non-Deleted user by email and org id
|
||||
GetNonDeletedUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error)
|
||||
|
||||
// Gets user_role with roles entries from db
|
||||
GetRolesByUserID(ctx context.Context, userID valuer.UUID) ([]*authtypes.UserRole, error)
|
||||
|
||||
// Gets all the user with role using role id in an org id
|
||||
// GetUsersByOrgIDAndRoleID backs OnBeforeRoleDelete.
|
||||
GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error)
|
||||
|
||||
// OnBeforeRoleDelete checks if any users are assigned to the role and rejects deletion if so.
|
||||
// OnBeforeRoleDelete refuses to delete a role that principals still hold.
|
||||
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
|
||||
|
||||
// VerifyResetPasswordToken checks if a reset password token exists and is not expired.
|
||||
VerifyResetPasswordToken(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
// invite
|
||||
CreateInvite(http.ResponseWriter, *http.Request)
|
||||
CreateBulkInvite(http.ResponseWriter, *http.Request)
|
||||
|
||||
// users
|
||||
ListUsersDeprecated(http.ResponseWriter, *http.Request)
|
||||
ListUsers(http.ResponseWriter, *http.Request)
|
||||
CreateUser(http.ResponseWriter, *http.Request)
|
||||
UpdateUserDeprecated(http.ResponseWriter, *http.Request)
|
||||
UpdateUser(http.ResponseWriter, *http.Request)
|
||||
DeleteUser(http.ResponseWriter, *http.Request)
|
||||
GetUserDeprecated(http.ResponseWriter, *http.Request)
|
||||
GetUser(http.ResponseWriter, *http.Request)
|
||||
GetMyUserDeprecated(http.ResponseWriter, *http.Request)
|
||||
// GetMyUser answers GET /v1/o11y/users/me from the request's CLAIMS. It is
|
||||
// the console's identity provider and the only user route o11y still serves.
|
||||
GetMyUser(http.ResponseWriter, *http.Request)
|
||||
UpdateMyUser(http.ResponseWriter, *http.Request)
|
||||
GetRolesByUserID(http.ResponseWriter, *http.Request)
|
||||
SetRoleByUserID(http.ResponseWriter, *http.Request)
|
||||
RemoveUserRoleByRoleID(http.ResponseWriter, *http.Request)
|
||||
GetUsersByRoleID(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Reset Password
|
||||
GetResetPasswordTokenDeprecated(http.ResponseWriter, *http.Request)
|
||||
GetResetPasswordToken(http.ResponseWriter, *http.Request)
|
||||
CreateResetPasswordToken(http.ResponseWriter, *http.Request)
|
||||
VerifyResetPasswordToken(http.ResponseWriter, *http.Request)
|
||||
ResetPassword(http.ResponseWriter, *http.Request)
|
||||
ChangePassword(http.ResponseWriter, *http.Request)
|
||||
ForgotPassword(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// OnDeleteUser lets other modules clean up data tied to a deleted user.
|
||||
type OnDeleteUser func(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package o11y
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/authn"
|
||||
"github.com/hanzoai/o11y/pkg/authn/callbackauthn/googlecallbackauthn"
|
||||
"github.com/hanzoai/o11y/pkg/authn/passwordauthn/emailpasswordauthn"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/global"
|
||||
"github.com/hanzoai/o11y/pkg/licensing"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
emailPasswordAuthN := emailpasswordauthn.New(store)
|
||||
|
||||
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings, globalConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[authtypes.AuthNProvider]authn.AuthN{
|
||||
authtypes.AuthNProviderEmailPassword: emailPasswordAuthN,
|
||||
authtypes.AuthNProviderGoogleAuth: googleCallbackAuthN,
|
||||
}, nil
|
||||
}
|
||||
@@ -41,7 +41,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/statsreporter"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/hanzoai/o11y/pkg/version"
|
||||
"github.com/hanzoai/o11y/pkg/web"
|
||||
@@ -113,9 +112,6 @@ type Config struct {
|
||||
// Gateway config
|
||||
Gateway gateway.Config `mapstructure:"gateway"`
|
||||
|
||||
// Tokenizer config
|
||||
Tokenizer tokenizer.Config `mapstructure:"tokenizer"`
|
||||
|
||||
// MetricsExplorer config
|
||||
MetricsExplorer metricsexplorer.Config `mapstructure:"metricsexplorer"`
|
||||
|
||||
@@ -175,7 +171,6 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
|
||||
sharder.NewConfigFactory(),
|
||||
statsreporter.NewConfigFactory(),
|
||||
gateway.NewConfigFactory(),
|
||||
tokenizer.NewConfigFactory(),
|
||||
metricsexplorer.NewConfigFactory(),
|
||||
inframonitoring.NewConfigFactory(),
|
||||
flagger.NewConfigFactory(),
|
||||
@@ -326,11 +321,6 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge
|
||||
config.Flagger.Config.Boolean[flagger.FeatureUseSpanMetrics.String()] = os.Getenv("USE_SPAN_METRICS") == "true"
|
||||
}
|
||||
|
||||
if os.Getenv("O11Y_JWT_SECRET") != "" {
|
||||
logger.WarnContext(ctx, "[Deprecated] env O11Y_JWT_SECRET is deprecated and scheduled for removal. Please use O11Y_TOKENIZER_JWT_SECRET instead.")
|
||||
config.Tokenizer.JWT.Secret = os.Getenv("O11Y_JWT_SECRET")
|
||||
}
|
||||
|
||||
if os.Getenv("KAFKA_SPAN_EVAL") != "" {
|
||||
logger.WarnContext(ctx, "[Deprecated] env KAFKA_SPAN_EVAL is deprecated and scheduled for removal. Please use O11Y_FLAGGER_CONFIG_BOOLEAN_KAFKA__SPAN__EVAL instead.")
|
||||
if config.Flagger.Config.Boolean == nil {
|
||||
@@ -355,9 +345,6 @@ func (config Config) Collect(_ context.Context, _ valuer.UUID) (map[string]any,
|
||||
// SQL Store Config Stats
|
||||
stats["config.sqlstore.provider"] = config.SQLStore.Provider
|
||||
|
||||
// Tokenizer Config Stats
|
||||
stats["config.tokenizer.provider"] = config.Tokenizer.Provider
|
||||
|
||||
// Cache Config Stats
|
||||
stats["config.cache.provider"] = config.Cache.Provider
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/sharder/noopsharder"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer/tokenizertest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -44,7 +43,6 @@ func TestNewHandlers(t *testing.T) {
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
|
||||
alertmanager, err := o11yalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
|
||||
require.NoError(t, err)
|
||||
tokenizer := tokenizertest.NewMockTokenizer(t)
|
||||
emailing := emailingtest.New()
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
require.NoError(t, err)
|
||||
@@ -59,7 +57,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
modules := NewModules(sqlstore, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
+1
-18
@@ -3,7 +3,6 @@ package o11y
|
||||
import (
|
||||
"github.com/hanzoai/o11y/pkg/alertmanager"
|
||||
"github.com/hanzoai/o11y/pkg/analytics"
|
||||
"github.com/hanzoai/o11y/pkg/authn"
|
||||
"github.com/hanzoai/o11y/pkg/authz"
|
||||
"github.com/hanzoai/o11y/pkg/cache"
|
||||
"github.com/hanzoai/o11y/pkg/emailing"
|
||||
@@ -11,8 +10,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/flagger"
|
||||
"github.com/hanzoai/o11y/pkg/modules/apdex"
|
||||
"github.com/hanzoai/o11y/pkg/modules/apdex/implapdex"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain/implauthdomain"
|
||||
"github.com/hanzoai/o11y/pkg/modules/cloudintegration"
|
||||
"github.com/hanzoai/o11y/pkg/modules/dashboard"
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking"
|
||||
@@ -48,8 +45,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/modules/serviceaccount"
|
||||
"github.com/hanzoai/o11y/pkg/modules/services"
|
||||
"github.com/hanzoai/o11y/pkg/modules/services/implservices"
|
||||
"github.com/hanzoai/o11y/pkg/modules/session"
|
||||
"github.com/hanzoai/o11y/pkg/modules/session/implsession"
|
||||
"github.com/hanzoai/o11y/pkg/modules/spanmapper"
|
||||
"github.com/hanzoai/o11y/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/hanzoai/o11y/pkg/modules/spanpercentile"
|
||||
@@ -66,7 +61,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/ruler/rulestore/sqlrulestore"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/preferencetypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
|
||||
@@ -85,8 +79,6 @@ type Modules struct {
|
||||
QuickFilter quickfilter.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
RawDataExport rawdataexport.Module
|
||||
AuthDomain authdomain.Module
|
||||
Session session.Module
|
||||
Services services.Module
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
@@ -115,7 +107,6 @@ type Modules struct {
|
||||
|
||||
func NewModules(
|
||||
sqlstore sqlstore.SQLStore,
|
||||
tokenizer tokenizer.Tokenizer,
|
||||
emailing emailing.Emailing,
|
||||
providerSettings factory.ProviderSettings,
|
||||
orgGetter organization.Getter,
|
||||
@@ -124,7 +115,6 @@ func NewModules(
|
||||
querier querier.Querier,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore,
|
||||
authNs map[authtypes.AuthNProvider]authn.AuthN,
|
||||
authz authz.AuthZ,
|
||||
cache cache.Cache,
|
||||
queryParser queryparser.QueryParser,
|
||||
@@ -141,13 +131,8 @@ func NewModules(
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
|
||||
// Cleanup callbacks from other modules, invoked when a user is deleted.
|
||||
onDeleteUser := []user.OnDeleteUser{
|
||||
dashboard.DeletePreferencesForUser,
|
||||
}
|
||||
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, onDeleteUser)
|
||||
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), providerSettings, authz, analytics, userRoleStore)
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
|
||||
|
||||
// Error tracking (issue lifecycle) and trace detail (the event.span waterfall)
|
||||
// are pulled into locals so the Sentry product face can COMPOSE them rather than
|
||||
@@ -186,8 +171,6 @@ func NewModules(
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/sharder/noopsharder"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer/tokenizertest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -46,7 +45,6 @@ func TestNewModules(t *testing.T) {
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
|
||||
alertmanager, err := o11yalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
|
||||
require.NoError(t, err)
|
||||
tokenizer := tokenizertest.NewMockTokenizer(t)
|
||||
emailing := emailingtest.New()
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
require.NoError(t, err)
|
||||
@@ -64,7 +62,7 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
modules := NewModules(sqlstore, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
+4
-37
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/analytics"
|
||||
"github.com/hanzoai/o11y/pkg/apiserver"
|
||||
"github.com/hanzoai/o11y/pkg/auditor"
|
||||
"github.com/hanzoai/o11y/pkg/authn"
|
||||
"github.com/hanzoai/o11y/pkg/authn/authnstore/sqlauthnstore"
|
||||
"github.com/hanzoai/o11y/pkg/authz"
|
||||
"github.com/hanzoai/o11y/pkg/cache"
|
||||
"github.com/hanzoai/o11y/pkg/emailing"
|
||||
@@ -24,7 +22,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/instrumentation"
|
||||
"github.com/hanzoai/o11y/pkg/licensing"
|
||||
"github.com/hanzoai/o11y/pkg/meterreporter"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain/implauthdomain"
|
||||
"github.com/hanzoai/o11y/pkg/modules/cloudintegration"
|
||||
"github.com/hanzoai/o11y/pkg/modules/dashboard"
|
||||
"github.com/hanzoai/o11y/pkg/modules/metricreductionrule"
|
||||
@@ -55,8 +52,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/telemetrymetrics"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrytraces"
|
||||
pkgtokenizer "github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
|
||||
"github.com/hanzoai/o11y/pkg/version"
|
||||
"github.com/hanzoai/o11y/pkg/zeus"
|
||||
@@ -83,7 +78,6 @@ type O11y struct {
|
||||
Emailing emailing.Emailing
|
||||
Sharder sharder.Sharder
|
||||
StatsReporter statsreporter.StatsReporter
|
||||
Tokenizer pkgtokenizer.Tokenizer
|
||||
IdentNResolver identn.IdentNResolver
|
||||
Authz authz.AuthZ
|
||||
Ruler ruler.Ruler
|
||||
@@ -109,7 +103,6 @@ func New(
|
||||
sqlSchemaProviderFactories func(sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]],
|
||||
sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]],
|
||||
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
|
||||
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
|
||||
authzCallback func(context.Context, sqlstore.SQLStore, authz.Config, licensing.Licensing, []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
|
||||
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
|
||||
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
|
||||
@@ -308,18 +301,6 @@ func New(
|
||||
// Initialize organization getter
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
|
||||
|
||||
// Initialize tokenizer from the available tokenizer provider factories
|
||||
tokenizer, err := factory.NewProviderFromNamedMap(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.Tokenizer,
|
||||
NewTokenizerProviderFactories(cache, sqlstore, orgGetter),
|
||||
config.Tokenizer.Provider,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize user store
|
||||
userStore := impluser.NewStore(sqlstore, providerSettings)
|
||||
|
||||
@@ -353,13 +334,10 @@ func New(
|
||||
// Initialize service account getter
|
||||
serviceAccountGetter := implserviceaccount.NewGetter(implserviceaccount.NewStore(sqlstore))
|
||||
|
||||
authDomainGetter := implauthdomain.NewGetter(implauthdomain.NewStore(sqlstore))
|
||||
|
||||
// Build pre-delete callbacks from modules
|
||||
onBeforeRoleDelete := []authz.OnBeforeRoleDelete{
|
||||
userGetter.OnBeforeRoleDelete,
|
||||
serviceAccountGetter.OnBeforeRoleDelete,
|
||||
authDomainGetter.OnBeforeRoleDelete,
|
||||
}
|
||||
|
||||
// Initialize authz
|
||||
@@ -417,13 +395,6 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize authns
|
||||
store := sqlauthnstore.NewStore(sqlstore)
|
||||
authNs, err := authNsCallback(ctx, providerSettings, store, licensing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize telemetry metadata store
|
||||
// TODO: consolidate other telemetrymetadata.NewTelemetryMetaStore initializations to reuse this instance instead.
|
||||
telemetryMetadataStore := telemetrymetadata.NewTelemetryMetaStore(
|
||||
@@ -474,7 +445,7 @@ func New(
|
||||
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
modules := NewModules(sqlstore, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "o11y")
|
||||
@@ -483,13 +454,13 @@ func New(
|
||||
}
|
||||
|
||||
// Initialize identN resolver
|
||||
identNFactories := NewIdentNProviderFactories(tokenizer, serviceAccount, orgGetter, modules.OrgSetter, authz, userGetter, modules.UserSetter, config.User)
|
||||
identNFactories := NewIdentNProviderFactories(serviceAccount, orgGetter, modules.OrgSetter, authz, userGetter, modules.UserSetter, config.User)
|
||||
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.UserGetter, modules.UserSetter, orgGetter, authz, config.User.Root)
|
||||
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.UserSetter, orgGetter, modules.OrgSetter, authz, config.User.Root)
|
||||
|
||||
// Initialize the querier handler via callback (allows EE to decorate with anomaly detection)
|
||||
querierHandler := querierHandlerCallback(providerSettings, querier, analytics)
|
||||
@@ -502,9 +473,7 @@ func New(
|
||||
modules.SavedView,
|
||||
modules.UserSetter,
|
||||
licensing,
|
||||
tokenizer,
|
||||
config,
|
||||
modules.AuthDomain,
|
||||
serviceAccount,
|
||||
cloudIntegrationModule,
|
||||
modules.LogsPipeline,
|
||||
@@ -521,7 +490,7 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.StatsReporter,
|
||||
NewStatsReporterProviderFactories(statsAggregator, orgGetter, userGetter, tokenizer, version.Info, config.Analytics),
|
||||
NewStatsReporterProviderFactories(statsAggregator, orgGetter, userGetter, version.Info, config.Analytics),
|
||||
config.StatsReporter.Provider(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -537,7 +506,6 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("alertmanager"), alertmanager),
|
||||
factory.NewNamedService(factory.MustNewName("licensing"), licensing),
|
||||
factory.NewNamedService(factory.MustNewName("statsreporter"), statsReporter),
|
||||
factory.NewNamedService(factory.MustNewName("tokenizer"), tokenizer),
|
||||
factory.NewNamedService(factory.MustNewName("authz"), authz),
|
||||
factory.NewNamedService(factory.MustNewName("user"), userService, factory.MustNewName("authz")),
|
||||
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
|
||||
@@ -582,7 +550,6 @@ func New(
|
||||
Licensing: licensing,
|
||||
Emailing: emailing,
|
||||
Sharder: sharder,
|
||||
Tokenizer: tokenizer,
|
||||
IdentNResolver: identNResolver,
|
||||
Authz: authz,
|
||||
Ruler: rulerInstance,
|
||||
|
||||
+4
-22
@@ -28,16 +28,13 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/identn/apikeyidentn"
|
||||
"github.com/hanzoai/o11y/pkg/identn/iamidentn"
|
||||
"github.com/hanzoai/o11y/pkg/identn/impersonationidentn"
|
||||
"github.com/hanzoai/o11y/pkg/identn/tokenizeridentn"
|
||||
"github.com/hanzoai/o11y/pkg/meterreporter"
|
||||
"github.com/hanzoai/o11y/pkg/meterreporter/noopmeterreporter"
|
||||
"github.com/hanzoai/o11y/pkg/modules/authdomain/implauthdomain"
|
||||
"github.com/hanzoai/o11y/pkg/modules/organization"
|
||||
"github.com/hanzoai/o11y/pkg/modules/organization/implorganization"
|
||||
"github.com/hanzoai/o11y/pkg/modules/preference/implpreference"
|
||||
"github.com/hanzoai/o11y/pkg/modules/promote/implpromote"
|
||||
"github.com/hanzoai/o11y/pkg/modules/serviceaccount"
|
||||
"github.com/hanzoai/o11y/pkg/modules/session/implsession"
|
||||
"github.com/hanzoai/o11y/pkg/modules/user"
|
||||
"github.com/hanzoai/o11y/pkg/modules/user/impluser"
|
||||
"github.com/hanzoai/o11y/pkg/pprof"
|
||||
@@ -62,10 +59,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore/datastoretelemetrystore"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore/telemetrystorehook"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer/jwttokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer/opaquetokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer/tokenizerstore/sqltokenizerstore"
|
||||
"github.com/hanzoai/o11y/pkg/types/alertmanagertypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/featuretypes"
|
||||
"github.com/hanzoai/o11y/pkg/version"
|
||||
@@ -273,9 +266,9 @@ func NewSharderProviderFactories() factory.NamedMap[factory.ProviderFactory[shar
|
||||
)
|
||||
}
|
||||
|
||||
func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.NamedMap[factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config]] {
|
||||
func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgGetter organization.Getter, userGetter user.Getter, build version.Build, analyticsConfig analytics.Config) factory.NamedMap[factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
analyticsstatsreporter.NewFactory(aggregator, orgGetter, userGetter, tokenizer, build, analyticsConfig),
|
||||
analyticsstatsreporter.NewFactory(aggregator, orgGetter, userGetter, build, analyticsConfig),
|
||||
noopstatsreporter.NewFactory(),
|
||||
)
|
||||
}
|
||||
@@ -292,9 +285,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
orgGetter,
|
||||
authz,
|
||||
implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
|
||||
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
|
||||
implsession.NewHandler(modules.Session, globalConfig),
|
||||
implauthdomain.NewHandler(modules.AuthDomain),
|
||||
impluser.NewHandler(),
|
||||
implpreference.NewHandler(modules.Preference),
|
||||
handlers.Global,
|
||||
implpromote.NewHandler(modules.Promote),
|
||||
@@ -327,19 +318,10 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
)
|
||||
}
|
||||
|
||||
func NewTokenizerProviderFactories(cache cache.Cache, sqlstore sqlstore.SQLStore, orgGetter organization.Getter) factory.NamedMap[factory.ProviderFactory[tokenizer.Tokenizer, tokenizer.Config]] {
|
||||
tokenStore := sqltokenizerstore.NewStore(sqlstore)
|
||||
return factory.MustNewNamedMap(
|
||||
opaquetokenizer.NewFactory(cache, tokenStore, orgGetter),
|
||||
jwttokenizer.NewFactory(cache, tokenStore),
|
||||
)
|
||||
}
|
||||
|
||||
func NewIdentNProviderFactories(tokenizer tokenizer.Tokenizer, serviceAccount serviceaccount.Module, orgGetter organization.Getter, orgSetter organization.Setter, authz authz.AuthZ, userGetter user.Getter, userSetter user.Setter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
|
||||
func NewIdentNProviderFactories(serviceAccount serviceaccount.Module, orgGetter organization.Getter, orgSetter organization.Setter, authz authz.AuthZ, userGetter user.Getter, userSetter user.Setter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
iamidentn.NewFactory(orgGetter, orgSetter, authz, userGetter, userSetter),
|
||||
impersonationidentn.NewFactory(orgGetter, userGetter, userConfig),
|
||||
tokenizeridentn.NewFactory(tokenizer),
|
||||
apikeyidentn.NewFactory(serviceAccount),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/statsreporter"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer/tokenizertest"
|
||||
"github.com/hanzoai/o11y/pkg/version"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -86,7 +85,7 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), instrumentationtest.New().ToProviderSettings()), userRoleStore, flagger)
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
|
||||
statsAggregator := statsreporter.NewAggregator(providerSettings, []statsreporter.StatsCollector{})
|
||||
NewStatsReporterProviderFactories(statsAggregator, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})
|
||||
NewStatsReporterProviderFactories(statsAggregator, orgGetter, userGetter, version.Build{}, analytics.Config{Enabled: true})
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
|
||||
@@ -48,9 +48,9 @@ import (
|
||||
// term the equality argument was missing.
|
||||
//
|
||||
// The sibling at pkg/apiserver/o11yapiserver/address_test.go argues set equality
|
||||
// from four facts: registered ⊆ declared on both halves, 233 + 133 = 366, and a
|
||||
// from four facts: registered ⊆ declared on both halves, 197 + 132 = 329, and a
|
||||
// duplicate registration panicking at boot. Three of those four were measured;
|
||||
// 134 was a sentence. So a route deleted HERE left both address tests green — the
|
||||
// the cardinality was a sentence. So a route deleted HERE left both address tests green — the
|
||||
// remaining ones are all still declared — while the declaration went on naming an
|
||||
// address nothing serves. That gap costs more since the seam started resolving by
|
||||
// name: it used to be the runtime's 404, and it is now a 503 that says the
|
||||
@@ -60,7 +60,7 @@ import (
|
||||
// the sets CONTAIN is checked by name below, and a cardinality is what closes
|
||||
// "⊆ on both halves" into "=". It is stated the way its sibling states its own so
|
||||
// the arithmetic reads as one argument in two files.
|
||||
const wantQueryServiceRoutes = 133
|
||||
const wantQueryServiceRoutes = 132
|
||||
|
||||
// BY NAME, NOT BY COUNT — see the argument in full at
|
||||
// pkg/apiserver/o11yapiserver/address_test.go. This is the other half of it: the
|
||||
@@ -129,9 +129,9 @@ func TestEveryRegisteredAddressIsDeclaredAndResolves(t *testing.T) {
|
||||
|
||||
table := r.Table()
|
||||
if got := len(table.Routes()); got != wantQueryServiceRoutes {
|
||||
t.Errorf("this package registers %d addresses, want %d — the declaration names 366 and "+
|
||||
t.Errorf("this package registers %d addresses, want %d — the declaration names 329 and "+
|
||||
"the other registrar %d, so a move here leaves an address declared and unserved",
|
||||
got, wantQueryServiceRoutes, 366-wantQueryServiceRoutes)
|
||||
got, wantQueryServiceRoutes, 329-wantQueryServiceRoutes)
|
||||
}
|
||||
for _, route := range table.Routes() {
|
||||
if !declared[route.Method+" "+route.Path] {
|
||||
|
||||
@@ -54,7 +54,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/query-service/constants"
|
||||
v3 "github.com/hanzoai/o11y/pkg/query-service/model/v3"
|
||||
"github.com/hanzoai/o11y/pkg/query-service/postprocess"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/ctxtypes"
|
||||
@@ -1150,32 +1149,6 @@ func (aH *APIHandler) getHealth(w http.ResponseWriter, r *http.Request) {
|
||||
aH.WriteJSON(w, r, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
|
||||
if aH.SetupCompleted {
|
||||
render.Error(w, errors.NewInvalidInputf(errors.CodeInvalidInput, "self-registration is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
var req types.PostableRegisterOrgAndAdmin
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
organization := types.NewOrganization(req.OrgDisplayName, req.OrgName)
|
||||
user, errv2 := aH.O11y.Modules.UserSetter.CreateFirstUser(r.Context(), organization, req.Name, req.Email, req.Password)
|
||||
if errv2 != nil {
|
||||
render.Error(w, errv2)
|
||||
return
|
||||
}
|
||||
|
||||
// since the first user is now created, we can disable self-registration as
|
||||
// from here onwards, we expect admin (owner) to invite other users.
|
||||
aH.SetupCompleted = true
|
||||
|
||||
aH.Respond(w, user)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) HandleError(w http.ResponseWriter, err error, statusCode int) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// What publish is FOR, verified the only way this defect was ever visible: by
|
||||
// the BODY of a request to the router, not by a status code.
|
||||
//
|
||||
// The conversion shipped 353 typed operations that no binary linked. Every proof
|
||||
// The conversion shipped 319 typed operations that no binary linked. Every proof
|
||||
// it had was taken inside a test process that imported the table directly, which
|
||||
// is exactly the thing that cannot fail: an uncalled package-level func is legal
|
||||
// Go, so the package built, the route arithmetic added up, and the server the
|
||||
@@ -110,20 +110,20 @@ func mcpTools(t *testing.T, app *zip.App) int {
|
||||
return len(out.Result.Tools)
|
||||
}
|
||||
|
||||
// THE PAYOFF, on the router the binary serves. 353 is the same count
|
||||
// THE PAYOFF, on the router the binary serves. 319 is the same count
|
||||
// routes_test.go pins on the table itself; asserting it HERE is what makes the
|
||||
// two the same 353 rather than two numbers that happen to agree.
|
||||
// two the same 319 rather than two numbers that happen to agree.
|
||||
func TestPublishServesTheDocument(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
if err := publish(app); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
if got := operations(t, documentOf(t, app)); got != 353 {
|
||||
t.Fatalf("the served document publishes %d operations, want 353", got)
|
||||
if got := operations(t, documentOf(t, app)); got != 319 {
|
||||
t.Fatalf("the served document publishes %d operations, want 319", got)
|
||||
}
|
||||
if got := mcpTools(t, app); got != 353 {
|
||||
t.Fatalf("the served router offers %d MCP tools, want 353", got)
|
||||
if got := mcpTools(t, app); got != 319 {
|
||||
t.Fatalf("the served router offers %d MCP tools, want 319", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ package app
|
||||
|
||||
// THE LINK, NOT THE FUNCTION.
|
||||
//
|
||||
// publish_test.go proves what publish DOES: mount it on a bare zip.App and 353
|
||||
// operations and 353 MCP tools appear. Every assertion it makes calls publish
|
||||
// directly — which is precisely the shape of the original defect. 353 typed ops
|
||||
// publish_test.go proves what publish DOES: mount it on a bare zip.App and 319
|
||||
// operations and 319 MCP tools appear. Every assertion it makes calls publish
|
||||
// directly — which is precisely the shape of the original defect. 319 typed ops
|
||||
// once shipped in files nothing called; the package built, the route arithmetic
|
||||
// added up, and the running server published nothing. A test that calls the
|
||||
// function itself cannot see that, because the thing that was missing was the
|
||||
|
||||
@@ -39,8 +39,6 @@ func (aH *APIHandler) mountMisc(router routing.Router, am *middleware.AuthZ) {
|
||||
|
||||
router.Get("/v1/o11y/disks", am.ViewAccess(aH.getDisks))
|
||||
|
||||
router.Post("/v1/o11y/register", am.OpenAccess(aH.registerUser))
|
||||
|
||||
router.Post("/v1/o11y/span_percentile", am.ViewAccess(aH.O11y.Handlers.SpanPercentile.GetSpanPercentileDetails))
|
||||
|
||||
// Query Filter Analyzer api used to extract metric names and grouping columns from a query
|
||||
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/modules/organization"
|
||||
"github.com/hanzoai/o11y/pkg/modules/user"
|
||||
"github.com/hanzoai/o11y/pkg/statsreporter"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/hanzoai/o11y/pkg/version"
|
||||
)
|
||||
|
||||
@@ -37,8 +35,6 @@ type provider struct {
|
||||
// used to get users
|
||||
userGetter user.Getter
|
||||
|
||||
// used to get tokenizer
|
||||
tokenizer tokenizer.Tokenizer
|
||||
|
||||
// used to send stats to an analytics backend
|
||||
analytics analytics.Analytics
|
||||
@@ -53,9 +49,9 @@ type provider struct {
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewFactory(aggregator statsreporter.Aggregator, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config] {
|
||||
func NewFactory(aggregator statsreporter.Aggregator, orgGetter organization.Getter, userGetter user.Getter, build version.Build, analyticsConfig analytics.Config) factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("analytics"), func(ctx context.Context, settings factory.ProviderSettings, config statsreporter.Config) (statsreporter.StatsReporter, error) {
|
||||
return New(ctx, settings, config, aggregator, orgGetter, userGetter, tokenizer, build, analyticsConfig)
|
||||
return New(ctx, settings, config, aggregator, orgGetter, userGetter, build, analyticsConfig)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +62,6 @@ func New(
|
||||
aggregator statsreporter.Aggregator,
|
||||
orgGetter organization.Getter,
|
||||
userGetter user.Getter,
|
||||
tokenizer tokenizer.Tokenizer,
|
||||
build version.Build,
|
||||
analyticsConfig analytics.Config,
|
||||
) (statsreporter.StatsReporter, error) {
|
||||
@@ -84,7 +79,6 @@ func New(
|
||||
orgGetter: orgGetter,
|
||||
userGetter: userGetter,
|
||||
analytics: analytics,
|
||||
tokenizer: tokenizer,
|
||||
build: build,
|
||||
deployment: deployment,
|
||||
stopC: make(chan struct{}),
|
||||
@@ -167,20 +161,11 @@ func (provider *provider) Report(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
maxLastObservedAtPerUserID, err := provider.tokenizer.ListMaxLastObservedAtByOrgID(ctx, org.ID)
|
||||
if err != nil {
|
||||
provider.settings.Logger().WarnContext(ctx, "failed to list max last observed at per user id", errors.Attr(err), slog.Any("org_id", org.ID))
|
||||
maxLastObservedAtPerUserID = make(map[valuer.UUID]time.Time)
|
||||
}
|
||||
|
||||
// The "last observed at" traits are gone with the tokenizer: they were
|
||||
// derived from o11y's OWN access tokens, and o11y no longer mints one.
|
||||
// Last-seen belongs to whoever mints the session — Hanzo IAM.
|
||||
for _, user := range users {
|
||||
traits := types.NewTraitsFromUser(user)
|
||||
if maxLastObservedAt, ok := maxLastObservedAtPerUserID[user.ID]; ok {
|
||||
traits["auth_token.last_observed_at.max.time"] = maxLastObservedAt.UTC()
|
||||
traits["auth_token.last_observed_at.max.time_unix"] = maxLastObservedAt.Unix()
|
||||
}
|
||||
|
||||
provider.analytics.IdentifyUser(ctx, org.ID.String(), user.ID.String(), traits)
|
||||
provider.analytics.IdentifyUser(ctx, org.ID.String(), user.ID.String(), types.NewTraitsFromUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
package tokenizer
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// The provider to use for tokenization.
|
||||
Provider string `mapstructure:"provider"`
|
||||
|
||||
// Config for the opaque tokenizer.
|
||||
Opaque OpaqueConfig `mapstructure:"opaque"`
|
||||
|
||||
// Config for the JWT tokenizer.
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
|
||||
// Rotation config
|
||||
Rotation RotationConfig `mapstructure:"rotation"`
|
||||
|
||||
// Lifetime config
|
||||
Lifetime LifetimeConfig `mapstructure:"lifetime"`
|
||||
}
|
||||
|
||||
type OpaqueConfig struct {
|
||||
// GC config
|
||||
GC GCConfig `mapstructure:"gc"`
|
||||
|
||||
// Token config
|
||||
Token TokenConfig `mapstructure:"token"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
// The secret to sign the JWT tokens.
|
||||
Secret string `mapstructure:"secret"`
|
||||
}
|
||||
|
||||
type GCConfig struct {
|
||||
// The interval to perform garbage collection.
|
||||
Interval time.Duration `mapstructure:"interval"`
|
||||
}
|
||||
|
||||
type RotationConfig struct {
|
||||
// The interval to rotate tokens in.
|
||||
Interval time.Duration `mapstructure:"interval"`
|
||||
|
||||
// The duration for which the previous token pair remains valid after a token pair is rotated.
|
||||
Duration time.Duration `mapstructure:"duration"`
|
||||
}
|
||||
|
||||
type LifetimeConfig struct {
|
||||
// The duration for which a user can be idle before being required to authenticate.
|
||||
Idle time.Duration `mapstructure:"idle"`
|
||||
|
||||
// The duration for which a user can remain logged in before being asked to login.
|
||||
Max time.Duration `mapstructure:"max"`
|
||||
}
|
||||
|
||||
type TokenConfig struct {
|
||||
// The maximum number of tokens a user can have.
|
||||
MaxPerUser int `mapstructure:"max_per_user"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("tokenizer"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Provider: "jwt",
|
||||
Opaque: OpaqueConfig{
|
||||
GC: GCConfig{
|
||||
Interval: 1 * time.Hour, // 1 hour
|
||||
},
|
||||
Token: TokenConfig{
|
||||
MaxPerUser: 5,
|
||||
},
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: "",
|
||||
},
|
||||
Rotation: RotationConfig{
|
||||
Interval: 30 * time.Minute, // 30 minutes
|
||||
Duration: 60 * time.Second, // 60 seconds
|
||||
},
|
||||
Lifetime: LifetimeConfig{
|
||||
Idle: 7 * 24 * time.Hour, // 7 days
|
||||
Max: 30 * 24 * time.Hour, // 30 days
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
// Ensure that lifetime idle is not negative
|
||||
if c.Lifetime.Idle < 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "lifetime::idle must not be negative")
|
||||
}
|
||||
|
||||
// Ensure that lifetime max is not negative
|
||||
if c.Lifetime.Max < 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "lifetime::max must not be negative")
|
||||
}
|
||||
|
||||
// Ensure that rotation interval is smaller than lifetime idle
|
||||
if c.Rotation.Interval >= c.Lifetime.Idle {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "rotation::interval must be smaller than lifetime::idle")
|
||||
}
|
||||
|
||||
// Ensure that lifetime idle interval is smaller than lifetime max
|
||||
if c.Lifetime.Idle >= c.Lifetime.Max {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "lifetime::idle must be smaller than lifetime::max")
|
||||
}
|
||||
|
||||
// Ensure that rotation duration is smaller than rotation interval
|
||||
if c.Rotation.Duration >= c.Rotation.Interval {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "rotation::duration must be smaller than rotation::interval")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package jwttokenizer
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
)
|
||||
|
||||
var _ jwt.ClaimsValidator = (*Claims)(nil)
|
||||
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
OrgID string `json:"orgId"`
|
||||
}
|
||||
|
||||
func (c *Claims) Validate() error {
|
||||
if c.UserID == "" {
|
||||
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "id is required")
|
||||
}
|
||||
|
||||
if c.OrgID == "" {
|
||||
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "orgId is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
package jwttokenizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/cache"
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
emptyOrgID valuer.UUID = valuer.UUID{}
|
||||
)
|
||||
|
||||
const (
|
||||
expectedLastObservedAtCacheEntries int64 = 5000
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
config tokenizer.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
cache cache.Cache
|
||||
tokenStore authtypes.TokenStore
|
||||
lastObservedAtCache *ristretto.Cache[string, map[valuer.UUID]time.Time]
|
||||
lastObservedAtMtx sync.RWMutex
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewFactory(cache cache.Cache, tokenStore authtypes.TokenStore) factory.ProviderFactory[tokenizer.Tokenizer, tokenizer.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("jwt"), func(ctx context.Context, providerSettings factory.ProviderSettings, config tokenizer.Config) (tokenizer.Tokenizer, error) {
|
||||
return New(ctx, providerSettings, config, cache, tokenStore)
|
||||
})
|
||||
}
|
||||
|
||||
func New(ctx context.Context, providerSettings factory.ProviderSettings, config tokenizer.Config, cache cache.Cache, tokenStore authtypes.TokenStore) (tokenizer.Tokenizer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/tokenizer/jwttokenizer")
|
||||
|
||||
if config.JWT.Secret == "" {
|
||||
settings.Logger().ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", slog.String("error", "O11Y_TOKENIZER_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the O11Y_TOKENIZER_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/hanzoai/o11y/issues/8400."))
|
||||
}
|
||||
|
||||
lastObservedAtCache, err := ristretto.NewCache(&ristretto.Config[string, map[valuer.UUID]time.Time]{
|
||||
NumCounters: 10 * expectedLastObservedAtCacheEntries, // 10x of expected entries
|
||||
MaxCost: 1 << 19, // ~ 512 KB
|
||||
BufferItems: 64,
|
||||
Metrics: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenizer.NewWrappedTokenizer(settings, &provider{
|
||||
config: config,
|
||||
settings: settings,
|
||||
cache: cache,
|
||||
tokenStore: tokenStore,
|
||||
lastObservedAtCache: lastObservedAtCache,
|
||||
lastObservedAtMtx: sync.RWMutex{},
|
||||
stopC: make(chan struct{}),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
<-provider.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) CreateToken(ctx context.Context, identity *authtypes.Identity, meta map[string]string) (*authtypes.Token, error) {
|
||||
accessTokenClaims := Claims{
|
||||
UserID: identity.UserID.String(),
|
||||
Email: identity.Email.String(),
|
||||
OrgID: identity.OrgID.String(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(provider.config.Rotation.Interval)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessTokenClaims).SignedString([]byte(provider.config.JWT.Secret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshTokenClaims := Claims{
|
||||
UserID: identity.UserID.String(),
|
||||
Email: identity.Email.String(),
|
||||
OrgID: identity.OrgID.String(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(provider.config.Lifetime.Max)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshTokenClaims).SignedString([]byte(provider.config.JWT.Secret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authtypes.NewTokenFromAccessTokenAndRefreshToken(accessToken, refreshToken, meta, identity.UserID)
|
||||
}
|
||||
|
||||
func (provider *provider) GetIdentity(ctx context.Context, accessToken string) (*authtypes.Identity, error) {
|
||||
claims, err := provider.getClaimsFromToken(accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authtypes.NewPrincipalUserIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), authtypes.IdentNProviderTokenizer), nil
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteToken(ctx context.Context, accessToken string) error {
|
||||
provider.settings.Logger().WarnContext(ctx, "Deleting token by access token is not supported for this tokenizer, this is a no-op", slog.String("tokenizer_provider", provider.config.Provider))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) RotateToken(ctx context.Context, _ string, refreshToken string) (*authtypes.Token, error) {
|
||||
claims, err := provider.getClaimsFromToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity, err := provider.getOrSetIdentity(ctx, emptyOrgID, valuer.MustNewUUID(claims.UserID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.CreateToken(ctx, identity, map[string]string{})
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteTokensByUserID(ctx context.Context, userID valuer.UUID) error {
|
||||
provider.settings.Logger().WarnContext(ctx, "Deleting token by user id is not supported for this tokenizer, this is a no-op", slog.String("tokenizer_provider", provider.config.Provider))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteIdentity(ctx context.Context, userID valuer.UUID) error {
|
||||
provider.cache.Delete(ctx, emptyOrgID, identityCacheKey(userID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) SetLastObservedAt(ctx context.Context, accessToken string, lastObservedAt time.Time) error {
|
||||
claims, err := provider.getClaimsFromToken(accessToken)
|
||||
if err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to set last observed at", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ristretto is returning the same map on concurrent calls.
|
||||
// Lock the last observed at cache to avoid race condition on writing to the same map obtained from the cache.
|
||||
provider.lastObservedAtMtx.Lock()
|
||||
defer provider.lastObservedAtMtx.Unlock()
|
||||
cachedLastObservedAts, ok := provider.lastObservedAtCache.Get(claims.OrgID)
|
||||
if !ok {
|
||||
cachedLastObservedAts = make(map[valuer.UUID]time.Time)
|
||||
}
|
||||
|
||||
cachedLastObservedAts[valuer.MustNewUUID(claims.UserID)] = lastObservedAt
|
||||
|
||||
if ok := provider.lastObservedAtCache.Set(claims.OrgID, cachedLastObservedAts, 1); !ok {
|
||||
provider.settings.Logger().ErrorContext(ctx, "error caching last observed at timestamp", slog.String("user_id", claims.UserID))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Config() tokenizer.Config {
|
||||
return provider.config
|
||||
}
|
||||
|
||||
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
userIDToLastObservedAts := provider.listLastObservedAtDesc(orgID)
|
||||
|
||||
if len(userIDToLastObservedAts) > 0 {
|
||||
userIDToLastObservedAtMax := userIDToLastObservedAts[0]
|
||||
for _, lastObservedAt := range userIDToLastObservedAtMax {
|
||||
if !lastObservedAt.IsZero() {
|
||||
stats["auth_token.last_observed_at.max.time"] = lastObservedAt.UTC()
|
||||
stats["auth_token.last_observed_at.max.time_unix"] = lastObservedAt.Unix()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (provider *provider) getClaimsFromToken(token string) (Claims, error) {
|
||||
claims := Claims{}
|
||||
|
||||
_, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.Newf(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unrecognized signing algorithm: %s", token.Method.Alg())
|
||||
}
|
||||
|
||||
return []byte(provider.config.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return Claims{}, errors.Wrapf(err, errors.TypeUnauthenticated, errors.CodeUnauthenticated, "failed to parse jwt token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
close(provider.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListMaxLastObservedAtByOrgID(ctx context.Context, orgID valuer.UUID) (map[valuer.UUID]time.Time, error) {
|
||||
userIDToLastObservedAts := provider.listLastObservedAtDesc(orgID)
|
||||
|
||||
maxLastObservedAtPerUserID := make(map[valuer.UUID]time.Time)
|
||||
|
||||
for _, userIDToLastObservedAt := range userIDToLastObservedAts {
|
||||
for userID, lastObservedAt := range userIDToLastObservedAt {
|
||||
if lastObservedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := maxLastObservedAtPerUserID[userID]; !ok {
|
||||
maxLastObservedAtPerUserID[userID] = lastObservedAt.UTC()
|
||||
continue
|
||||
}
|
||||
|
||||
if lastObservedAt.UTC().After(maxLastObservedAtPerUserID[userID]) {
|
||||
maxLastObservedAtPerUserID[userID] = lastObservedAt.UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxLastObservedAtPerUserID, nil
|
||||
}
|
||||
|
||||
func (provider *provider) getOrSetIdentity(ctx context.Context, orgID, userID valuer.UUID) (*authtypes.Identity, error) {
|
||||
identity := new(authtypes.Identity)
|
||||
|
||||
err := provider.cache.Get(ctx, orgID, identityCacheKey(userID), identity)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to get identity from cache", errors.Attr(err))
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
identity, err = provider.tokenStore.GetIdentityByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = provider.cache.Set(ctx, orgID, identityCacheKey(identity.UserID), identity, 0)
|
||||
if err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to cache identity", errors.Attr(err))
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (provider *provider) listLastObservedAtDesc(orgID valuer.UUID) []map[valuer.UUID]time.Time {
|
||||
var userIDToLastObservedAt []map[valuer.UUID]time.Time
|
||||
|
||||
cachedLastObservedAts, ok := provider.lastObservedAtCache.Get(orgID.String())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for userID, lastObservedAt := range cachedLastObservedAts {
|
||||
userIDToLastObservedAt = append(userIDToLastObservedAt, map[valuer.UUID]time.Time{
|
||||
userID: lastObservedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// sort by descending order of last_observed_at
|
||||
slices.SortFunc(userIDToLastObservedAt, func(a, b map[valuer.UUID]time.Time) int {
|
||||
var aT, bT time.Time
|
||||
|
||||
for _, t := range a {
|
||||
aT = t
|
||||
break
|
||||
}
|
||||
|
||||
for _, t := range b {
|
||||
bT = t
|
||||
break
|
||||
}
|
||||
|
||||
return bT.Compare(aT)
|
||||
})
|
||||
|
||||
return userIDToLastObservedAt
|
||||
}
|
||||
|
||||
func identityCacheKey(userID valuer.UUID) string {
|
||||
return "identity::" + userID.String()
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package jwttokenizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/hanzoai/o11y/pkg/cache"
|
||||
"github.com/hanzoai/o11y/pkg/cache/cachetest"
|
||||
"github.com/hanzoai/o11y/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer/tokenizerstore/sqltokenizerstore"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestProvider(t *testing.T) tokenizer.Tokenizer {
|
||||
instrumentation := instrumentationtest.New()
|
||||
cache, err := cachetest.New(cache.Config{
|
||||
Memory: cache.Memory{
|
||||
NumCounters: 1000,
|
||||
MaxCost: 1 << 26,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sqlstore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := New(
|
||||
context.Background(),
|
||||
instrumentation.ToProviderSettings(),
|
||||
tokenizer.Config{
|
||||
Rotation: tokenizer.RotationConfig{
|
||||
Interval: 1 * time.Second, // 1 second
|
||||
Duration: 60 * time.Second, // 60 seconds
|
||||
},
|
||||
Lifetime: tokenizer.LifetimeConfig{
|
||||
Idle: 7 * 24 * time.Hour, // 7 days
|
||||
Max: 30 * 24 * time.Hour, // 30 days
|
||||
}},
|
||||
cache,
|
||||
sqltokenizerstore.NewStore(sqlstore),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
func TestLastObservedAt_Concurrent(t *testing.T) {
|
||||
provider := newTestProvider(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
token1, err := provider.CreateToken(
|
||||
context.Background(),
|
||||
&authtypes.Identity{
|
||||
UserID: valuer.GenerateUUID(),
|
||||
OrgID: orgID,
|
||||
Email: valuer.MustNewEmail("[email protected]"),
|
||||
},
|
||||
map[string]string{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
token2, err := provider.CreateToken(
|
||||
context.Background(),
|
||||
&authtypes.Identity{
|
||||
UserID: valuer.GenerateUUID(),
|
||||
OrgID: orgID,
|
||||
Email: valuer.MustNewEmail("[email protected]"),
|
||||
},
|
||||
map[string]string{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
assert.NoError(t, provider.SetLastObservedAt(context.Background(), token1.AccessToken, time.Now()))
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
assert.NoError(t, provider.SetLastObservedAt(context.Background(), token2.AccessToken, time.Now()))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
package opaquetokenizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/cache"
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/modules/organization"
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/cachetypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
emptyOrgID valuer.UUID = valuer.UUID{}
|
||||
)
|
||||
|
||||
const (
|
||||
expectedLastObservedAtCacheEntries int64 = 5000 // 1000 users * Max 5 tokens per user
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
config tokenizer.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
cache cache.Cache
|
||||
tokenStore authtypes.TokenStore
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
lastObservedAtCache *ristretto.Cache[string, time.Time]
|
||||
}
|
||||
|
||||
func NewFactory(cache cache.Cache, tokenStore authtypes.TokenStore, orgGetter organization.Getter) factory.ProviderFactory[tokenizer.Tokenizer, tokenizer.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("opaque"), func(ctx context.Context, providerSettings factory.ProviderSettings, config tokenizer.Config) (tokenizer.Tokenizer, error) {
|
||||
return New(ctx, providerSettings, config, cache, tokenStore, orgGetter)
|
||||
})
|
||||
}
|
||||
|
||||
func New(ctx context.Context, providerSettings factory.ProviderSettings, config tokenizer.Config, cache cache.Cache, tokenStore authtypes.TokenStore, orgGetter organization.Getter) (tokenizer.Tokenizer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/tokenizer/opaquetokenizer")
|
||||
|
||||
// * move these hardcoded values to a config based value when needed
|
||||
lastObservedAtCache, err := ristretto.NewCache(&ristretto.Config[string, time.Time]{
|
||||
NumCounters: 10 * expectedLastObservedAtCacheEntries, // 10x of expected entries
|
||||
MaxCost: 1 << 19, // ~ 512 KB
|
||||
BufferItems: 64,
|
||||
Metrics: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenizer.NewWrappedTokenizer(settings, &provider{
|
||||
config: config,
|
||||
settings: settings,
|
||||
cache: cache,
|
||||
tokenStore: tokenStore,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
lastObservedAtCache: lastObservedAtCache,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(provider.config.Opaque.GC.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-provider.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
ctx, span := provider.settings.Tracer().Start(ctx, "tokenizer.GC", trace.WithAttributes(attribute.String("tokenizer.provider", provider.config.Provider)))
|
||||
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to get orgs data", errors.Attr(err))
|
||||
span.End()
|
||||
continue
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := provider.gc(ctx, org); err != nil {
|
||||
span.RecordError(err)
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to garbage collect tokens", errors.Attr(err), slog.Any("org_id", org.ID))
|
||||
}
|
||||
|
||||
if err := provider.flushLastObservedAt(ctx, org); err != nil {
|
||||
span.RecordError(err)
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", errors.Attr(err), slog.Any("org_id", org.ID))
|
||||
}
|
||||
}
|
||||
|
||||
span.End()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *provider) CreateToken(ctx context.Context, identity *authtypes.Identity, meta map[string]string) (*authtypes.Token, error) {
|
||||
existingTokens, err := provider.tokenStore.ListByUserID(ctx, identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(existingTokens) >= provider.config.Opaque.Token.MaxPerUser {
|
||||
slices.SortFunc(existingTokens, func(a, b *authtypes.Token) int {
|
||||
return a.CreatedAt.Compare(b.CreatedAt)
|
||||
})
|
||||
|
||||
if err := provider.DeleteToken(ctx, existingTokens[0].AccessToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
token, err := authtypes.NewToken(meta, identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := provider.setToken(ctx, token, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := provider.setIdentity(ctx, identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetIdentity(ctx context.Context, accessToken string) (*authtypes.Identity, error) {
|
||||
token, err := provider.getOrGetSetToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := token.IsValid(provider.config.Rotation.Interval, provider.config.Lifetime.Idle, provider.config.Lifetime.Max); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity, err := provider.getOrGetSetIdentity(ctx, token.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (provider *provider) RotateToken(ctx context.Context, accessToken string, refreshToken string) (*authtypes.Token, error) {
|
||||
var rotatedToken *authtypes.Token
|
||||
|
||||
if err := provider.tokenStore.GetOrUpdateByAccessTokenOrPrevAccessToken(ctx, accessToken, func(ctx context.Context, token *authtypes.StorableToken) error {
|
||||
if err := token.Rotate(accessToken, refreshToken, provider.config.Rotation.Duration, provider.config.Lifetime.Idle, provider.config.Lifetime.Max); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the token passed the Rotate method and is the same as the input token, return the same token.
|
||||
if token.AccessToken == accessToken && token.RefreshToken == refreshToken {
|
||||
rotatedToken = token
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := provider.setToken(ctx, token, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the previous access token from the cache
|
||||
provider.cache.Delete(ctx, emptyOrgID, accessTokenCacheKey(accessToken))
|
||||
|
||||
rotatedToken = token
|
||||
return nil
|
||||
}); err != nil {
|
||||
// If the token is not found, return an unauthenticated error.
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, errors.Wrap(err, errors.TypeUnauthenticated, errors.CodeUnauthenticated, "invalid access token")
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rotatedToken, nil
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteToken(ctx context.Context, accessToken string) error {
|
||||
provider.cache.Delete(ctx, emptyOrgID, accessTokenCacheKey(accessToken))
|
||||
if err := provider.tokenStore.DeleteByAccessToken(ctx, accessToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteTokensByUserID(ctx context.Context, userID valuer.UUID) error {
|
||||
tokens, err := provider.tokenStore.ListByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, token := range tokens {
|
||||
provider.cache.Delete(ctx, emptyOrgID, accessTokenCacheKey(token.AccessToken))
|
||||
}
|
||||
|
||||
if err := provider.tokenStore.DeleteByUserID(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteIdentity(ctx context.Context, userID valuer.UUID) error {
|
||||
provider.cache.Delete(ctx, emptyOrgID, identityCacheKey(userID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
close(provider.stopC)
|
||||
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
// garbage collect tokens on stop
|
||||
if err := provider.gc(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to garbage collect tokens", errors.Attr(err), slog.Any("org_id", org.ID))
|
||||
}
|
||||
|
||||
// flush tokens on stop
|
||||
if err := provider.flushLastObservedAt(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", errors.Attr(err), slog.Any("org_id", org.ID))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) SetLastObservedAt(ctx context.Context, accessToken string, lastObservedAt time.Time) error {
|
||||
token, err := provider.getOrGetSetToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If we can't update the last observed at, we return nil.
|
||||
if err := token.UpdateLastObservedAt(lastObservedAt); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ok := provider.lastObservedAtCache.Set(lastObservedAtCacheKey(accessToken, token.UserID), lastObservedAt, 24); !ok {
|
||||
provider.settings.Logger().ErrorContext(ctx, "error caching last observed at timestamp", slog.Any("user_id", token.UserID))
|
||||
}
|
||||
|
||||
err = provider.cache.Set(ctx, emptyOrgID, accessTokenCacheKey(accessToken), token, provider.config.Lifetime.Max)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Config() tokenizer.Config {
|
||||
return provider.config
|
||||
}
|
||||
|
||||
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
tokens, err := provider.tokenStore.ListByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := make(map[string]any)
|
||||
stats["auth_token.count"] = len(tokens)
|
||||
|
||||
accessTokenToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(accessTokenToLastObservedAt) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
accessTokenToLastObservedAtMax := accessTokenToLastObservedAt[0]
|
||||
|
||||
if lastObservedAt, ok := accessTokenToLastObservedAtMax["last_observed_at"].(time.Time); ok {
|
||||
if !lastObservedAt.IsZero() {
|
||||
stats["auth_token.last_observed_at.max.time"] = lastObservedAt.UTC()
|
||||
stats["auth_token.last_observed_at.max.time_unix"] = lastObservedAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListMaxLastObservedAtByOrgID(ctx context.Context, orgID valuer.UUID) (map[valuer.UUID]time.Time, error) {
|
||||
accessTokenToLastObservedAts, err := provider.listLastObservedAtDesc(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxLastObservedAtPerUserID := make(map[valuer.UUID]time.Time)
|
||||
|
||||
for _, accessTokenToLastObservedAt := range accessTokenToLastObservedAts {
|
||||
userID, ok := accessTokenToLastObservedAt["user_id"].(valuer.UUID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
lastObservedAt, ok := accessTokenToLastObservedAt["last_observed_at"].(time.Time)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if lastObservedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := maxLastObservedAtPerUserID[userID]; !ok {
|
||||
maxLastObservedAtPerUserID[userID] = lastObservedAt.UTC()
|
||||
continue
|
||||
}
|
||||
|
||||
if lastObservedAt.UTC().After(maxLastObservedAtPerUserID[userID]) {
|
||||
maxLastObservedAtPerUserID[userID] = lastObservedAt.UTC()
|
||||
}
|
||||
}
|
||||
|
||||
return maxLastObservedAtPerUserID, nil
|
||||
|
||||
}
|
||||
|
||||
func (provider *provider) gc(ctx context.Context, org *types.Organization) error {
|
||||
tokens, err := provider.tokenStore.ListByOrgID(ctx, org.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tokensToDelete []valuer.UUID
|
||||
for _, token := range tokens {
|
||||
if err := token.IsExpired(provider.config.Lifetime.Idle, provider.config.Lifetime.Max); err != nil {
|
||||
tokensToDelete = append(tokensToDelete, token.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tokensToDelete) > 0 {
|
||||
err := provider.tokenStore.DeleteMany(ctx, tokensToDelete)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) flushLastObservedAt(ctx context.Context, org *types.Organization) error {
|
||||
accessTokenToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, org.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.tokenStore.UpdateLastObservedAtByAccessToken(ctx, accessTokenToLastObservedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) getOrGetSetToken(ctx context.Context, accessToken string) (*authtypes.Token, error) {
|
||||
token := new(authtypes.Token)
|
||||
err := provider.cache.Get(ctx, emptyOrgID, accessTokenCacheKey(accessToken), token)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
token, err = provider.tokenStore.GetByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = provider.cache.Set(ctx, emptyOrgID, accessTokenCacheKey(accessToken), token, provider.config.Lifetime.Max)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (provider *provider) setToken(ctx context.Context, token *authtypes.Token, create bool) error {
|
||||
err := provider.cache.Set(ctx, emptyOrgID, accessTokenCacheKey(token.AccessToken), token, provider.config.Lifetime.Max)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if create {
|
||||
return provider.tokenStore.Create(ctx, token)
|
||||
}
|
||||
|
||||
return provider.tokenStore.Update(ctx, token)
|
||||
}
|
||||
|
||||
func (provider *provider) setIdentity(ctx context.Context, identity *authtypes.Identity) error {
|
||||
err := provider.cache.Set(ctx, emptyOrgID, identityCacheKey(identity.UserID), identity, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) getOrGetSetIdentity(ctx context.Context, userID valuer.UUID) (*authtypes.Identity, error) {
|
||||
identity := new(authtypes.Identity)
|
||||
err := provider.cache.Get(ctx, emptyOrgID, identityCacheKey(userID), identity)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
identity, err = provider.tokenStore.GetIdentityByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = provider.cache.Set(ctx, emptyOrgID, identityCacheKey(userID), identity, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (provider *provider) listLastObservedAtDesc(ctx context.Context, orgID valuer.UUID) ([]map[string]any, error) {
|
||||
tokens, err := provider.tokenStore.ListByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var accessTokenToLastObservedAt []map[string]any
|
||||
|
||||
for _, token := range tokens {
|
||||
tokenCachedLastObservedAt, ok := provider.lastObservedAtCache.Get(lastObservedAtCacheKey(token.AccessToken, token.UserID))
|
||||
if ok {
|
||||
accessTokenToLastObservedAt = append(accessTokenToLastObservedAt, map[string]any{
|
||||
"user_id": token.UserID,
|
||||
"access_token": token.AccessToken,
|
||||
"last_observed_at": tokenCachedLastObservedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sort by descending order of last_observed_at
|
||||
slices.SortFunc(accessTokenToLastObservedAt, func(a, b map[string]any) int {
|
||||
return b["last_observed_at"].(time.Time).Compare(a["last_observed_at"].(time.Time))
|
||||
})
|
||||
|
||||
return accessTokenToLastObservedAt, nil
|
||||
}
|
||||
|
||||
func accessTokenCacheKey(accessToken string) string {
|
||||
return "access_token::" + cachetypes.NewSha1CacheKey(accessToken)
|
||||
}
|
||||
|
||||
func identityCacheKey(userID valuer.UUID) string {
|
||||
return "identity::" + userID.String()
|
||||
}
|
||||
|
||||
func lastObservedAtCacheKey(accessToken string, userID valuer.UUID) string {
|
||||
return "access_token::" + accessToken + "::" + userID.String()
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package tokenizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/statsreporter"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type Tokenizer interface {
|
||||
factory.Service
|
||||
// Create a new token.
|
||||
CreateToken(context.Context, *authtypes.Identity, map[string]string) (*authtypes.Token, error)
|
||||
|
||||
// Get identity from token.
|
||||
GetIdentity(context.Context, string) (*authtypes.Identity, error)
|
||||
|
||||
// Rotate the input token and return a new token.
|
||||
RotateToken(context.Context, string, string) (*authtypes.Token, error)
|
||||
|
||||
// Delete the token by access token.
|
||||
DeleteToken(context.Context, string) error
|
||||
|
||||
// Delete all tokens by userID.
|
||||
DeleteTokensByUserID(context.Context, valuer.UUID) error
|
||||
|
||||
// Delete the identity by userID.
|
||||
DeleteIdentity(context.Context, valuer.UUID) error
|
||||
|
||||
// Set the last observed at for an access token.
|
||||
SetLastObservedAt(context.Context, string, time.Time) error
|
||||
|
||||
// Returns the config of the tokenizer.
|
||||
Config() Config
|
||||
|
||||
// Gets the last observed at for each user in an org.
|
||||
ListMaxLastObservedAtByOrgID(context.Context, valuer.UUID) (map[valuer.UUID]time.Time, error)
|
||||
|
||||
statsreporter.StatsCollector
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package sqltokenizerstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) authtypes.TokenStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, token *authtypes.StorableToken) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(token).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetIdentityByUserID(ctx context.Context, userID valuer.UUID) (*authtypes.Identity, error) {
|
||||
user := new(types.User)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(user).
|
||||
Where("id = ?", userID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with id: %s does not exist", userID)
|
||||
}
|
||||
|
||||
return authtypes.NewPrincipalUserIdentity(userID, user.OrgID, user.Email, authtypes.IdentNProviderTokenizer), nil
|
||||
}
|
||||
|
||||
func (store *store) GetByAccessToken(ctx context.Context, accessToken string) (*authtypes.StorableToken, error) {
|
||||
token := new(authtypes.StorableToken)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(token).
|
||||
Where("access_token = ?", accessToken).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeTokenNotFound, "token does not exist", accessToken)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (store *store) GetOrUpdateByAccessTokenOrPrevAccessToken(ctx context.Context, accessToken string, updater func(ctx context.Context, token *authtypes.StorableToken) error) error {
|
||||
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
token := new(authtypes.StorableToken)
|
||||
|
||||
selectQuery := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(token).
|
||||
Where("access_token = ?", accessToken).
|
||||
WhereOr("prev_access_token = ?", accessToken)
|
||||
|
||||
if store.sqlstore.BunDBCtx(ctx).Dialect().Name() != dialect.SQLite {
|
||||
selectQuery = selectQuery.For("UPDATE")
|
||||
}
|
||||
|
||||
err := selectQuery.Scan(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeTokenNotFound, "token does not exist", accessToken)
|
||||
}
|
||||
|
||||
if err := updater(ctx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *store) GetByUserIDAndRefreshToken(ctx context.Context, userID valuer.UUID, refreshToken string) (*authtypes.StorableToken, error) {
|
||||
token := new(authtypes.StorableToken)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(token).
|
||||
Where("user_id = ?", userID).
|
||||
Where("refresh_token = ?", refreshToken).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeTokenNotFound, "token with user id: %s and refresh token: %s does not exist", userID, refreshToken)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (store *store) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*authtypes.StorableToken, error) {
|
||||
tokens := make([]*authtypes.StorableToken, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tokens).
|
||||
Join("JOIN users").
|
||||
JoinOn("users.id = auth_token.user_id").
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (store *store) ListByOrgIDs(ctx context.Context, orgIDs []valuer.UUID) ([]*authtypes.StorableToken, error) {
|
||||
tokens := make([]*authtypes.StorableToken, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tokens).
|
||||
Join("JOIN users").
|
||||
JoinOn("users.id = auth_token.user_id").
|
||||
Join("JOIN organizations").
|
||||
JoinOn("organizations.id = users.org_id").
|
||||
Where("organizations.id IN (?)", bun.In(orgIDs)).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, token *authtypes.StorableToken) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(token).
|
||||
Where("id = ?", token.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteByAccessToken(ctx context.Context, accessToken string) error {
|
||||
token := new(authtypes.StorableToken)
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(token).
|
||||
Where("access_token = ?", accessToken).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteMany(ctx context.Context, tokenIDs []valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(authtypes.StorableToken)).
|
||||
Where("id IN (?)", tokenIDs).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteByUserID(ctx context.Context, userID valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(authtypes.StorableToken)).
|
||||
Where("user_id = ?", userID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) ListByUserID(ctx context.Context, userID valuer.UUID) ([]*authtypes.StorableToken, error) {
|
||||
var tokens []*authtypes.StorableToken
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tokens).
|
||||
Where("user_id = ?", userID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, accessTokenToLastObservedAt []map[string]any) error {
|
||||
values := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewValues(&accessTokenToLastObservedAt)
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
With("update_cte", values).
|
||||
Model((*authtypes.StorableToken)(nil)).
|
||||
TableExpr("update_cte").
|
||||
Set("last_observed_at = update_cte.last_observed_at").
|
||||
Where("auth_token.access_token = update_cte.access_token").
|
||||
Where("auth_token.user_id = update_cte.user_id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,774 +0,0 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package tokenizertest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/tokenizer"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewMockTokenizer creates a new instance of MockTokenizer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockTokenizer(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockTokenizer {
|
||||
mock := &MockTokenizer{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// MockTokenizer is an autogenerated mock type for the Tokenizer type
|
||||
type MockTokenizer struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockTokenizer_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockTokenizer) EXPECT() *MockTokenizer_Expecter {
|
||||
return &MockTokenizer_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Collect provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) Collect(context1 context.Context, uUID valuer.UUID) (map[string]any, error) {
|
||||
ret := _mock.Called(context1, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Collect")
|
||||
}
|
||||
|
||||
var r0 map[string]any
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) (map[string]any, error)); ok {
|
||||
return returnFunc(context1, uUID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) map[string]any); ok {
|
||||
r0 = returnFunc(context1, uUID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(map[string]any)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, valuer.UUID) error); ok {
|
||||
r1 = returnFunc(context1, uUID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockTokenizer_Collect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Collect'
|
||||
type MockTokenizer_Collect_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Collect is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockTokenizer_Expecter) Collect(context1 interface{}, uUID interface{}) *MockTokenizer_Collect_Call {
|
||||
return &MockTokenizer_Collect_Call{Call: _e.mock.On("Collect", context1, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Collect_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockTokenizer_Collect_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 valuer.UUID
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(valuer.UUID)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Collect_Call) Return(stringToV map[string]any, err error) *MockTokenizer_Collect_Call {
|
||||
_c.Call.Return(stringToV, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Collect_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) (map[string]any, error)) *MockTokenizer_Collect_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Config provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) Config() tokenizer.Config {
|
||||
ret := _mock.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Config")
|
||||
}
|
||||
|
||||
var r0 tokenizer.Config
|
||||
if returnFunc, ok := ret.Get(0).(func() tokenizer.Config); ok {
|
||||
r0 = returnFunc()
|
||||
} else {
|
||||
r0 = ret.Get(0).(tokenizer.Config)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockTokenizer_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config'
|
||||
type MockTokenizer_Config_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Config is a helper method to define mock.On call
|
||||
func (_e *MockTokenizer_Expecter) Config() *MockTokenizer_Config_Call {
|
||||
return &MockTokenizer_Config_Call{Call: _e.mock.On("Config")}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Config_Call) Run(run func()) *MockTokenizer_Config_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Config_Call) Return(config tokenizer.Config) *MockTokenizer_Config_Call {
|
||||
_c.Call.Return(config)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Config_Call) RunAndReturn(run func() tokenizer.Config) *MockTokenizer_Config_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// CreateToken provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) CreateToken(context1 context.Context, identity *authtypes.Identity, stringToString map[string]string) (*authtypes.Token, error) {
|
||||
ret := _mock.Called(context1, identity, stringToString)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateToken")
|
||||
}
|
||||
|
||||
var r0 *authtypes.Token
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *authtypes.Identity, map[string]string) (*authtypes.Token, error)); ok {
|
||||
return returnFunc(context1, identity, stringToString)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *authtypes.Identity, map[string]string) *authtypes.Token); ok {
|
||||
r0 = returnFunc(context1, identity, stringToString)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*authtypes.Token)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *authtypes.Identity, map[string]string) error); ok {
|
||||
r1 = returnFunc(context1, identity, stringToString)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockTokenizer_CreateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateToken'
|
||||
type MockTokenizer_CreateToken_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CreateToken is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - identity *authtypes.Identity
|
||||
// - stringToString map[string]string
|
||||
func (_e *MockTokenizer_Expecter) CreateToken(context1 interface{}, identity interface{}, stringToString interface{}) *MockTokenizer_CreateToken_Call {
|
||||
return &MockTokenizer_CreateToken_Call{Call: _e.mock.On("CreateToken", context1, identity, stringToString)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_CreateToken_Call) Run(run func(context1 context.Context, identity *authtypes.Identity, stringToString map[string]string)) *MockTokenizer_CreateToken_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *authtypes.Identity
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*authtypes.Identity)
|
||||
}
|
||||
var arg2 map[string]string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(map[string]string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_CreateToken_Call) Return(token *authtypes.Token, err error) *MockTokenizer_CreateToken_Call {
|
||||
_c.Call.Return(token, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_CreateToken_Call) RunAndReturn(run func(context1 context.Context, identity *authtypes.Identity, stringToString map[string]string) (*authtypes.Token, error)) *MockTokenizer_CreateToken_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// DeleteIdentity provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) DeleteIdentity(context1 context.Context, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteIdentity")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, uUID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockTokenizer_DeleteIdentity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteIdentity'
|
||||
type MockTokenizer_DeleteIdentity_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// DeleteIdentity is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockTokenizer_Expecter) DeleteIdentity(context1 interface{}, uUID interface{}) *MockTokenizer_DeleteIdentity_Call {
|
||||
return &MockTokenizer_DeleteIdentity_Call{Call: _e.mock.On("DeleteIdentity", context1, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteIdentity_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockTokenizer_DeleteIdentity_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 valuer.UUID
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(valuer.UUID)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteIdentity_Call) Return(err error) *MockTokenizer_DeleteIdentity_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteIdentity_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) error) *MockTokenizer_DeleteIdentity_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// DeleteToken provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) DeleteToken(context1 context.Context, s string) error {
|
||||
ret := _mock.Called(context1, s)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteToken")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
|
||||
r0 = returnFunc(context1, s)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockTokenizer_DeleteToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteToken'
|
||||
type MockTokenizer_DeleteToken_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// DeleteToken is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
func (_e *MockTokenizer_Expecter) DeleteToken(context1 interface{}, s interface{}) *MockTokenizer_DeleteToken_Call {
|
||||
return &MockTokenizer_DeleteToken_Call{Call: _e.mock.On("DeleteToken", context1, s)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteToken_Call) Run(run func(context1 context.Context, s string)) *MockTokenizer_DeleteToken_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteToken_Call) Return(err error) *MockTokenizer_DeleteToken_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteToken_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockTokenizer_DeleteToken_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// DeleteTokensByUserID provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) DeleteTokensByUserID(context1 context.Context, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteTokensByUserID")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, uUID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockTokenizer_DeleteTokensByUserID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteTokensByUserID'
|
||||
type MockTokenizer_DeleteTokensByUserID_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// DeleteTokensByUserID is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockTokenizer_Expecter) DeleteTokensByUserID(context1 interface{}, uUID interface{}) *MockTokenizer_DeleteTokensByUserID_Call {
|
||||
return &MockTokenizer_DeleteTokensByUserID_Call{Call: _e.mock.On("DeleteTokensByUserID", context1, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteTokensByUserID_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockTokenizer_DeleteTokensByUserID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 valuer.UUID
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(valuer.UUID)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteTokensByUserID_Call) Return(err error) *MockTokenizer_DeleteTokensByUserID_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_DeleteTokensByUserID_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) error) *MockTokenizer_DeleteTokensByUserID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetIdentity provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) GetIdentity(context1 context.Context, s string) (*authtypes.Identity, error) {
|
||||
ret := _mock.Called(context1, s)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetIdentity")
|
||||
}
|
||||
|
||||
var r0 *authtypes.Identity
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*authtypes.Identity, error)); ok {
|
||||
return returnFunc(context1, s)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) *authtypes.Identity); ok {
|
||||
r0 = returnFunc(context1, s)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*authtypes.Identity)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = returnFunc(context1, s)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockTokenizer_GetIdentity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIdentity'
|
||||
type MockTokenizer_GetIdentity_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetIdentity is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
func (_e *MockTokenizer_Expecter) GetIdentity(context1 interface{}, s interface{}) *MockTokenizer_GetIdentity_Call {
|
||||
return &MockTokenizer_GetIdentity_Call{Call: _e.mock.On("GetIdentity", context1, s)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_GetIdentity_Call) Run(run func(context1 context.Context, s string)) *MockTokenizer_GetIdentity_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_GetIdentity_Call) Return(identity *authtypes.Identity, err error) *MockTokenizer_GetIdentity_Call {
|
||||
_c.Call.Return(identity, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_GetIdentity_Call) RunAndReturn(run func(context1 context.Context, s string) (*authtypes.Identity, error)) *MockTokenizer_GetIdentity_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// ListMaxLastObservedAtByOrgID provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) ListMaxLastObservedAtByOrgID(context1 context.Context, uUID valuer.UUID) (map[valuer.UUID]time.Time, error) {
|
||||
ret := _mock.Called(context1, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ListMaxLastObservedAtByOrgID")
|
||||
}
|
||||
|
||||
var r0 map[valuer.UUID]time.Time
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) (map[valuer.UUID]time.Time, error)); ok {
|
||||
return returnFunc(context1, uUID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) map[valuer.UUID]time.Time); ok {
|
||||
r0 = returnFunc(context1, uUID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(map[valuer.UUID]time.Time)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, valuer.UUID) error); ok {
|
||||
r1 = returnFunc(context1, uUID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockTokenizer_ListMaxLastObservedAtByOrgID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListMaxLastObservedAtByOrgID'
|
||||
type MockTokenizer_ListMaxLastObservedAtByOrgID_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ListMaxLastObservedAtByOrgID is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockTokenizer_Expecter) ListMaxLastObservedAtByOrgID(context1 interface{}, uUID interface{}) *MockTokenizer_ListMaxLastObservedAtByOrgID_Call {
|
||||
return &MockTokenizer_ListMaxLastObservedAtByOrgID_Call{Call: _e.mock.On("ListMaxLastObservedAtByOrgID", context1, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_ListMaxLastObservedAtByOrgID_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockTokenizer_ListMaxLastObservedAtByOrgID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 valuer.UUID
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(valuer.UUID)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_ListMaxLastObservedAtByOrgID_Call) Return(uUIDToTime map[valuer.UUID]time.Time, err error) *MockTokenizer_ListMaxLastObservedAtByOrgID_Call {
|
||||
_c.Call.Return(uUIDToTime, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_ListMaxLastObservedAtByOrgID_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) (map[valuer.UUID]time.Time, error)) *MockTokenizer_ListMaxLastObservedAtByOrgID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// RotateToken provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) RotateToken(context1 context.Context, s string, s1 string) (*authtypes.Token, error) {
|
||||
ret := _mock.Called(context1, s, s1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RotateToken")
|
||||
}
|
||||
|
||||
var r0 *authtypes.Token
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*authtypes.Token, error)); ok {
|
||||
return returnFunc(context1, s, s1)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *authtypes.Token); ok {
|
||||
r0 = returnFunc(context1, s, s1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*authtypes.Token)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
|
||||
r1 = returnFunc(context1, s, s1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockTokenizer_RotateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RotateToken'
|
||||
type MockTokenizer_RotateToken_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// RotateToken is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - s1 string
|
||||
func (_e *MockTokenizer_Expecter) RotateToken(context1 interface{}, s interface{}, s1 interface{}) *MockTokenizer_RotateToken_Call {
|
||||
return &MockTokenizer_RotateToken_Call{Call: _e.mock.On("RotateToken", context1, s, s1)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_RotateToken_Call) Run(run func(context1 context.Context, s string, s1 string)) *MockTokenizer_RotateToken_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_RotateToken_Call) Return(token *authtypes.Token, err error) *MockTokenizer_RotateToken_Call {
|
||||
_c.Call.Return(token, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_RotateToken_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string) (*authtypes.Token, error)) *MockTokenizer_RotateToken_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetLastObservedAt provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) SetLastObservedAt(context1 context.Context, s string, time1 time.Time) error {
|
||||
ret := _mock.Called(context1, s, time1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SetLastObservedAt")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) error); ok {
|
||||
r0 = returnFunc(context1, s, time1)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockTokenizer_SetLastObservedAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetLastObservedAt'
|
||||
type MockTokenizer_SetLastObservedAt_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// SetLastObservedAt is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - time1 time.Time
|
||||
func (_e *MockTokenizer_Expecter) SetLastObservedAt(context1 interface{}, s interface{}, time1 interface{}) *MockTokenizer_SetLastObservedAt_Call {
|
||||
return &MockTokenizer_SetLastObservedAt_Call{Call: _e.mock.On("SetLastObservedAt", context1, s, time1)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_SetLastObservedAt_Call) Run(run func(context1 context.Context, s string, time1 time.Time)) *MockTokenizer_SetLastObservedAt_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 time.Time
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(time.Time)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_SetLastObservedAt_Call) Return(err error) *MockTokenizer_SetLastObservedAt_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_SetLastObservedAt_Call) RunAndReturn(run func(context1 context.Context, s string, time1 time.Time) error) *MockTokenizer_SetLastObservedAt_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Start provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) Start(context1 context.Context) error {
|
||||
ret := _mock.Called(context1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Start")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
|
||||
r0 = returnFunc(context1)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockTokenizer_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
|
||||
type MockTokenizer_Start_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Start is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
func (_e *MockTokenizer_Expecter) Start(context1 interface{}) *MockTokenizer_Start_Call {
|
||||
return &MockTokenizer_Start_Call{Call: _e.mock.On("Start", context1)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Start_Call) Run(run func(context1 context.Context)) *MockTokenizer_Start_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Start_Call) Return(err error) *MockTokenizer_Start_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Start_Call) RunAndReturn(run func(context1 context.Context) error) *MockTokenizer_Start_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Stop provides a mock function for the type MockTokenizer
|
||||
func (_mock *MockTokenizer) Stop(context1 context.Context) error {
|
||||
ret := _mock.Called(context1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Stop")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
|
||||
r0 = returnFunc(context1)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockTokenizer_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
|
||||
type MockTokenizer_Stop_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Stop is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
func (_e *MockTokenizer_Expecter) Stop(context1 interface{}) *MockTokenizer_Stop_Call {
|
||||
return &MockTokenizer_Stop_Call{Call: _e.mock.On("Stop", context1)}
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Stop_Call) Run(run func(context1 context.Context)) *MockTokenizer_Stop_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Stop_Call) Return(err error) *MockTokenizer_Stop_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTokenizer_Stop_Call) RunAndReturn(run func(context1 context.Context) error) *MockTokenizer_Stop_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package tokenizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/factory"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
type wrappedTokenizer struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
tokenizer Tokenizer
|
||||
}
|
||||
|
||||
func NewWrappedTokenizer(settings factory.ScopedProviderSettings, tokenizer Tokenizer) Tokenizer {
|
||||
return &wrappedTokenizer{
|
||||
settings: settings,
|
||||
tokenizer: tokenizer,
|
||||
}
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) Start(ctx context.Context) error {
|
||||
return wrapped.tokenizer.Start(ctx)
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) Stop(ctx context.Context) error {
|
||||
return wrapped.tokenizer.Stop(ctx)
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) CreateToken(ctx context.Context, identity *authtypes.Identity, meta map[string]string) (*authtypes.Token, error) {
|
||||
ctx, span := wrapped.settings.Tracer().Start(ctx, "tokenizer.CreateToken", trace.WithAttributes(attribute.String("tokenizer.provider", wrapped.tokenizer.Config().Provider)))
|
||||
defer span.End()
|
||||
|
||||
token, err := wrapped.tokenizer.CreateToken(ctx, identity, meta)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) GetIdentity(ctx context.Context, accessToken string) (*authtypes.Identity, error) {
|
||||
ctx, span := wrapped.settings.Tracer().Start(ctx, "tokenizer.GetIdentity", trace.WithAttributes(attribute.String("tokenizer.provider", wrapped.tokenizer.Config().Provider)))
|
||||
defer span.End()
|
||||
|
||||
identity, err := wrapped.tokenizer.GetIdentity(ctx, accessToken)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) RotateToken(ctx context.Context, accessToken string, refreshToken string) (*authtypes.Token, error) {
|
||||
ctx, span := wrapped.settings.Tracer().Start(ctx, "tokenizer.RotateToken", trace.WithAttributes(attribute.String("tokenizer.provider", wrapped.tokenizer.Config().Provider)))
|
||||
defer span.End()
|
||||
|
||||
token, err := wrapped.tokenizer.RotateToken(ctx, accessToken, refreshToken)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) DeleteToken(ctx context.Context, accessToken string) error {
|
||||
ctx, span := wrapped.settings.Tracer().Start(ctx, "tokenizer.DeleteToken", trace.WithAttributes(attribute.String("tokenizer.provider", wrapped.tokenizer.Config().Provider)))
|
||||
defer span.End()
|
||||
|
||||
err := wrapped.tokenizer.DeleteToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) DeleteTokensByUserID(ctx context.Context, userID valuer.UUID) error {
|
||||
ctx, span := wrapped.settings.Tracer().Start(ctx, "tokenizer.DeleteTokensByUserID", trace.WithAttributes(attribute.String("tokenizer.provider", wrapped.tokenizer.Config().Provider)))
|
||||
defer span.End()
|
||||
|
||||
err := wrapped.tokenizer.DeleteTokensByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) DeleteIdentity(ctx context.Context, userID valuer.UUID) error {
|
||||
ctx, span := wrapped.settings.Tracer().Start(ctx, "tokenizer.DeleteIdentity", trace.WithAttributes(attribute.String("tokenizer.provider", wrapped.tokenizer.Config().Provider)))
|
||||
defer span.End()
|
||||
|
||||
err := wrapped.tokenizer.DeleteIdentity(ctx, userID)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) SetLastObservedAt(ctx context.Context, accessToken string, lastObservedAt time.Time) error {
|
||||
ctx, span := wrapped.settings.Tracer().Start(ctx, "tokenizer.SetLastObservedAt", trace.WithAttributes(attribute.String("tokenizer.provider", wrapped.tokenizer.Config().Provider)))
|
||||
defer span.End()
|
||||
|
||||
err := wrapped.tokenizer.SetLastObservedAt(ctx, accessToken, lastObservedAt)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) Config() Config {
|
||||
return wrapped.tokenizer.Config()
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) ListMaxLastObservedAtByOrgID(ctx context.Context, orgID valuer.UUID) (map[valuer.UUID]time.Time, error) {
|
||||
return wrapped.tokenizer.ListMaxLastObservedAtByOrgID(ctx, orgID)
|
||||
}
|
||||
|
||||
func (wrapped *wrappedTokenizer) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
return wrapped.tokenizer.Collect(ctx, orgID)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
|
||||
claims := authtypes.Claims{IdentNProvider: authtypes.IdentNProviderTokenizer}
|
||||
claims := authtypes.Claims{IdentNProvider: authtypes.IdentNProviderIAM}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestNewAuditEvent(t *testing.T) {
|
||||
statusCode: http.StatusOK,
|
||||
action: coretypes.VerbCreate,
|
||||
category: coretypes.ActionCategoryConfigurationChange,
|
||||
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "[email protected]", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
|
||||
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "[email protected]", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderIAM},
|
||||
resource: testDashboardResource,
|
||||
resourceID: "019b-5678-efgh-9012",
|
||||
expectedOutcome: OutcomeSuccess,
|
||||
@@ -57,7 +57,7 @@ func TestNewAuditEvent(t *testing.T) {
|
||||
statusCode: http.StatusForbidden,
|
||||
action: coretypes.VerbUpdate,
|
||||
category: coretypes.ActionCategoryConfigurationChange,
|
||||
claims: authtypes.Claims{UserID: "019aaaaa-bbbb-7000-8000-cccc00000002", Email: "[email protected]", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
|
||||
claims: authtypes.Claims{UserID: "019aaaaa-bbbb-7000-8000-cccc00000002", Email: "[email protected]", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderIAM},
|
||||
resource: testDashboardResource,
|
||||
resourceID: "019b-5678-efgh-9012",
|
||||
errorType: "forbidden",
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeInvalidState = errors.MustNewCode("invalid_state")
|
||||
)
|
||||
|
||||
var (
|
||||
AuthNProviderGoogleAuth = AuthNProvider{valuer.NewString("google_auth")}
|
||||
AuthNProviderSAML = AuthNProvider{valuer.NewString("saml")}
|
||||
AuthNProviderEmailPassword = AuthNProvider{valuer.NewString("email_password")}
|
||||
AuthNProviderOIDC = AuthNProvider{valuer.NewString("oidc")}
|
||||
)
|
||||
|
||||
var (
|
||||
PrincipalUser = Principal{valuer.NewString("user")}
|
||||
PrincipalServiceAccount = Principal{valuer.NewString("service_account")}
|
||||
)
|
||||
|
||||
type AuthNProvider struct{ valuer.String }
|
||||
|
||||
type Principal struct{ valuer.String }
|
||||
|
||||
type Identity struct {
|
||||
UserID valuer.UUID `json:"userId"`
|
||||
ServiceAccountID valuer.UUID `json:"serviceAccountId"`
|
||||
Principal Principal `json:"principal"`
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
IdenNProvider IdentNProvider `json:"identNProvider"`
|
||||
Email valuer.Email `json:"email"`
|
||||
}
|
||||
|
||||
type CallbackIdentity struct {
|
||||
Name string `json:"name"`
|
||||
Email valuer.Email `json:"email"`
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
State State `json:"state"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
DomainID valuer.UUID
|
||||
URL *url.URL
|
||||
}
|
||||
|
||||
func NewState(siteURL *url.URL, domainID valuer.UUID) State {
|
||||
u := &url.URL{
|
||||
Scheme: siteURL.Scheme,
|
||||
Host: siteURL.Host,
|
||||
Path: siteURL.Path,
|
||||
RawQuery: url.Values{
|
||||
"domain_id": {newDomainIDForState(domainID)},
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
return State{
|
||||
DomainID: domainID,
|
||||
URL: u,
|
||||
}
|
||||
}
|
||||
|
||||
func NewStateFromString(state string) (State, error) {
|
||||
u, err := url.Parse(state)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
|
||||
domainID, err := newDomainIDFromState(u.Query().Get("domain_id"))
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
|
||||
return State{
|
||||
DomainID: domainID,
|
||||
URL: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewIdentity(userID valuer.UUID, serviceAccountID valuer.UUID, principal Principal, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
|
||||
return &Identity{
|
||||
UserID: userID,
|
||||
ServiceAccountID: serviceAccountID,
|
||||
Principal: principal,
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
IdenNProvider: identNProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPrincipalUserIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
|
||||
return &Identity{
|
||||
UserID: userID,
|
||||
Principal: PrincipalUser,
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
IdenNProvider: identNProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPrincipalServiceAccountIdentity(serviceAccountID valuer.UUID, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
|
||||
return &Identity{
|
||||
ServiceAccountID: serviceAccountID,
|
||||
Principal: PrincipalServiceAccount,
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
IdenNProvider: identNProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func NewCallbackIdentity(name string, email valuer.Email, orgID valuer.UUID, state State, groups []string, role string) *CallbackIdentity {
|
||||
return &CallbackIdentity{
|
||||
Name: name,
|
||||
Email: email,
|
||||
OrgID: orgID,
|
||||
State: state,
|
||||
Groups: groups,
|
||||
Role: role,
|
||||
}
|
||||
}
|
||||
|
||||
func newDomainIDForState(domainID valuer.UUID) string {
|
||||
return strings.ReplaceAll(domainID.String(), "-", ":")
|
||||
}
|
||||
|
||||
func newDomainIDFromState(state string) (valuer.UUID, error) {
|
||||
return valuer.NewUUID(strings.ReplaceAll(state, ":", "-"))
|
||||
}
|
||||
|
||||
func (typ Identity) MarshalBinary() ([]byte, error) {
|
||||
return json.Marshal(typ)
|
||||
}
|
||||
|
||||
func (typ *Identity) UnmarshalBinary(data []byte) error {
|
||||
return json.Unmarshal(data, typ)
|
||||
}
|
||||
|
||||
func (typ *Identity) ToClaims() Claims {
|
||||
return Claims{
|
||||
UserID: typ.UserID.String(),
|
||||
ServiceAccountID: typ.ServiceAccountID.String(),
|
||||
Principal: typ.Principal,
|
||||
Email: typ.Email.String(),
|
||||
OrgID: typ.OrgID.String(),
|
||||
IdentNProvider: typ.IdenNProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthNProvider) Enum() []any {
|
||||
return []any{
|
||||
AuthNProviderGoogleAuth,
|
||||
AuthNProviderSAML,
|
||||
AuthNProviderEmailPassword,
|
||||
AuthNProviderOIDC,
|
||||
}
|
||||
}
|
||||
|
||||
type AuthNStore interface {
|
||||
// Get user and factor password by email and orgID.
|
||||
GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, []*UserRole, error)
|
||||
|
||||
// Get org domain from id.
|
||||
GetAuthDomainFromID(ctx context.Context, domainID valuer.UUID) (*AuthDomain, error)
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const (
|
||||
authDomainNameRegexString string = `^([a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62}){1}(\.[a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})*?$`
|
||||
)
|
||||
|
||||
var (
|
||||
authDomainNameRegex = regexp.MustCompile(authDomainNameRegexString)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeAuthDomainInvalidConfig = errors.MustNewCode("auth_domain_invalid_config")
|
||||
ErrCodeAuthDomainInvalidName = errors.MustNewCode("auth_domain_invalid_name")
|
||||
ErrCodeAuthDomainMismatch = errors.MustNewCode("auth_domain_mismatch")
|
||||
ErrCodeAuthDomainNotFound = errors.MustNewCode("auth_domain_not_found")
|
||||
ErrCodeAuthDomainAlreadyExists = errors.MustNewCode("auth_domain_already_exists")
|
||||
)
|
||||
|
||||
type GettableAuthDomain struct {
|
||||
StorableAuthDomain
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
|
||||
}
|
||||
|
||||
type AuthNProviderInfo struct {
|
||||
RelayStatePath *string `json:"relayStatePath"`
|
||||
}
|
||||
|
||||
type PostableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type UpdatableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
}
|
||||
|
||||
type StorableAuthDomain struct {
|
||||
bun.BaseModel `bun:"table:auth_domain"`
|
||||
|
||||
types.Identifiable
|
||||
Name string `bun:"name" json:"name"`
|
||||
Data string `bun:"data" json:"-"`
|
||||
OrgID valuer.UUID `bun:"org_id" json:"orgId"`
|
||||
types.TimeAuditable
|
||||
}
|
||||
|
||||
// TODO: the oneOf emitted by JSONSchemaOneOf is not the shape OpenAPI wants
|
||||
// for a discriminated union. OpenAPI's discriminator requires every oneOf
|
||||
// branch to be a $ref to a named component and a sibling property whose value
|
||||
// selects the variant. ssoType is already discriminator-shaped, but the
|
||||
// variant payload lives in a sibling field (samlConfig / googleAuthConfig /
|
||||
// oidcConfig) instead of being the payload itself, so no discriminator can
|
||||
// be attached. Refactor AuthDomainConfig into an envelope (see
|
||||
// ruletypes.RuleThresholdData for the pattern) where the chosen config is
|
||||
// the payload and ssoType is the discriminator.
|
||||
type AuthDomainConfig struct {
|
||||
SSOEnabled bool `json:"ssoEnabled"`
|
||||
AuthNProvider AuthNProvider `json:"ssoType"`
|
||||
SAML *SamlConfig `json:"samlConfig"`
|
||||
Google *GoogleConfig `json:"googleAuthConfig"`
|
||||
OIDC *OIDCConfig `json:"oidcConfig"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type AuthDomain struct {
|
||||
storableAuthDomain *StorableAuthDomain
|
||||
authDomainConfig *AuthDomainConfig
|
||||
}
|
||||
|
||||
func NewAuthDomainFromConfig(name string, config *AuthDomainConfig, orgID valuer.UUID) (*AuthDomain, error) {
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewAuthDomain(name, string(data), orgID)
|
||||
}
|
||||
|
||||
func NewAuthDomain(name string, data string, orgID valuer.UUID) (*AuthDomain, error) {
|
||||
storableAuthDomain := &StorableAuthDomain{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Name: name,
|
||||
Data: data,
|
||||
OrgID: orgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
return NewAuthDomainFromStorableAuthDomain(storableAuthDomain)
|
||||
}
|
||||
|
||||
func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain) (*AuthDomain, error) {
|
||||
authDomainConfig := new(AuthDomainConfig)
|
||||
if err := json.Unmarshal([]byte(storableAuthDomain.Data), authDomainConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthDomain{
|
||||
storableAuthDomain: storableAuthDomain,
|
||||
authDomainConfig: authDomainConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
|
||||
return &GettableAuthDomain{
|
||||
StorableAuthDomain: *authDomain.StorableAuthDomain(),
|
||||
Config: *authDomain.AuthDomainConfig(),
|
||||
AuthNProviderInfo: authNProviderInfo,
|
||||
}
|
||||
}
|
||||
|
||||
func (typ *AuthDomain) StorableAuthDomain() *StorableAuthDomain {
|
||||
return typ.storableAuthDomain
|
||||
}
|
||||
|
||||
func (typ *AuthDomain) AuthDomainConfig() *AuthDomainConfig {
|
||||
return typ.authDomainConfig
|
||||
}
|
||||
|
||||
func (typ *AuthDomain) Update(config *AuthDomainConfig) error {
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typ.authDomainConfig = config
|
||||
typ.storableAuthDomain.Data = string(data)
|
||||
typ.storableAuthDomain.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableAuthDomain
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !authDomainNameRegex.MatchString(temp.Name) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidName, "invalid domain name %s", temp.Name)
|
||||
}
|
||||
|
||||
*typ = PostableAuthDomain(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias AuthDomainConfig
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch temp.AuthNProvider {
|
||||
case AuthNProviderGoogleAuth:
|
||||
if temp.Google == nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
|
||||
}
|
||||
|
||||
case AuthNProviderSAML:
|
||||
if temp.SAML == nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "saml config is required")
|
||||
}
|
||||
|
||||
case AuthNProviderOIDC:
|
||||
if temp.OIDC == nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "oidc config is required")
|
||||
}
|
||||
|
||||
default:
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", temp.AuthNProvider.StringValue())
|
||||
}
|
||||
|
||||
*typ = AuthDomainConfig(temp)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (AuthDomainConfig) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
SamlConfig{},
|
||||
GoogleConfig{},
|
||||
OIDCConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
type AuthDomainStore interface {
|
||||
// Get by id.
|
||||
Get(context.Context, valuer.UUID) (*AuthDomain, error)
|
||||
|
||||
// Get by orgID and id.
|
||||
GetByOrgIDAndID(context.Context, valuer.UUID, valuer.UUID) (*AuthDomain, error)
|
||||
|
||||
// Get by name.
|
||||
GetByName(context.Context, string) (*AuthDomain, error)
|
||||
|
||||
// Get by name and orgID.
|
||||
GetByNameAndOrgID(context.Context, string, valuer.UUID) (*AuthDomain, error)
|
||||
|
||||
// List org domains by orgID.
|
||||
ListByOrgID(context.Context, valuer.UUID) ([]*AuthDomain, error)
|
||||
|
||||
// Create auth domain.
|
||||
Create(context.Context, *AuthDomain) error
|
||||
|
||||
// Update by orgID and id.
|
||||
Update(context.Context, *AuthDomain) error
|
||||
|
||||
// Delete by orgID and id.
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type PostableEmailPasswordSession struct {
|
||||
Email valuer.Email `json:"email"`
|
||||
Password string `json:"password"`
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
}
|
||||
|
||||
func (typ *PostableEmailPasswordSession) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableEmailPasswordSession
|
||||
var temp Alias
|
||||
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.Email.IsZero() {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
|
||||
}
|
||||
|
||||
if temp.Password == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "password is required")
|
||||
}
|
||||
|
||||
if temp.OrgID.IsZero() {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
|
||||
}
|
||||
|
||||
*typ = PostableEmailPasswordSession(temp)
|
||||
return nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
const wildCardDomain = "*"
|
||||
|
||||
type GoogleConfig struct {
|
||||
// ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.
|
||||
ClientID string `json:"clientId"`
|
||||
|
||||
// It is the application's secret.
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
|
||||
// What is the meaning of this? Should we remove this?
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
|
||||
// Whether to fetch the Google workspace groups (required additional API scopes)
|
||||
FetchGroups bool `json:"fetchGroups"`
|
||||
|
||||
// Service Account creds JSON stored for Google Admin SDK access
|
||||
// This is content of the JSON file stored directly into db as string
|
||||
// Required if FetchGroups is true (unless running on GCE with default credentials)
|
||||
ServiceAccountJSON string `json:"serviceAccountJson,omitempty"`
|
||||
|
||||
// Map of workspace domain to admin email for service account impersonation
|
||||
// The service account will impersonate this admin to call the directory API
|
||||
// Use "*" as key for wildcard/default that matches any domain
|
||||
// Example: {"example.com": "[email protected]", "*": "[email protected]"}
|
||||
DomainToAdminEmail map[string]valuer.Email `json:"domainToAdminEmail,omitempty"`
|
||||
|
||||
// If true, fetch transitive group membership (recursive - groups that contains other groups)
|
||||
FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership,omitempty"`
|
||||
|
||||
// Optional list of allowed groups
|
||||
// If this is present, only users belonging to one of these groups will be allowed to login
|
||||
AllowedGroups []string `json:"allowedGroups,omitempty"`
|
||||
|
||||
// Whether to skip email verification. Defaults to "false"
|
||||
InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"`
|
||||
}
|
||||
|
||||
func (config *GoogleConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias GoogleConfig
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.ClientID == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "clientId is required")
|
||||
}
|
||||
|
||||
if temp.ClientSecret == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "clientSecret is required")
|
||||
}
|
||||
|
||||
if temp.FetchGroups {
|
||||
if len(temp.DomainToAdminEmail) == 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "domainToAdminEmail is required if fetchGroups is true")
|
||||
}
|
||||
|
||||
if temp.ServiceAccountJSON == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "serviceAccountJSON is required if fetchGroups is true")
|
||||
}
|
||||
}
|
||||
|
||||
if len(temp.AllowedGroups) > 0 && !temp.FetchGroups {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "fetchGroups must be true when allowedGroups is configured")
|
||||
}
|
||||
|
||||
*config = GoogleConfig(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *GoogleConfig) GetAdminEmailForDomain(userEmail string) string {
|
||||
domain := extractDomainFromEmail(userEmail)
|
||||
|
||||
if adminEmail, ok := config.DomainToAdminEmail[domain]; ok {
|
||||
return adminEmail.StringValue()
|
||||
}
|
||||
|
||||
return config.DomainToAdminEmail[wildCardDomain].StringValue()
|
||||
}
|
||||
|
||||
func extractDomainFromEmail(email string) string {
|
||||
if at := strings.LastIndex(email, "@"); at >= 0 {
|
||||
return email[at+1:]
|
||||
}
|
||||
return wildCardDomain
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// Identity is WHO a request is, as resolved by an identN provider. It is
|
||||
// resolved, never issued: o11y reads it out of what the edge asserted (the
|
||||
// gateway's identity headers), or out of a service-account API key, and turns
|
||||
// it into the Claims every handler downstream reads.
|
||||
//
|
||||
// This file is what survived pkg/types/authtypes/authn.go. The rest of that
|
||||
// file described how o11y AUTHENTICATED people — providers named
|
||||
// email_password / google_auth / saml / oidc, a callback identity carried
|
||||
// through an OAuth `state`, an AuthNStore that read a user's stored password.
|
||||
// o11y authenticates nobody now; Hanzo IAM does, once, at the edge.
|
||||
var (
|
||||
PrincipalUser = Principal{valuer.NewString("user")}
|
||||
PrincipalServiceAccount = Principal{valuer.NewString("service_account")}
|
||||
)
|
||||
|
||||
// Principal is WHAT KIND of thing the identity is — a person or a machine. It
|
||||
// is the one axis authorization branches on before it looks at roles.
|
||||
type Principal struct{ valuer.String }
|
||||
|
||||
type Identity struct {
|
||||
UserID valuer.UUID `json:"userId"`
|
||||
ServiceAccountID valuer.UUID `json:"serviceAccountId"`
|
||||
Principal Principal `json:"principal"`
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
IdenNProvider IdentNProvider `json:"identNProvider"`
|
||||
Email valuer.Email `json:"email"`
|
||||
}
|
||||
|
||||
func NewIdentity(userID valuer.UUID, serviceAccountID valuer.UUID, principal Principal, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
|
||||
return &Identity{
|
||||
UserID: userID,
|
||||
ServiceAccountID: serviceAccountID,
|
||||
Principal: principal,
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
IdenNProvider: identNProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPrincipalUserIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
|
||||
return &Identity{
|
||||
UserID: userID,
|
||||
Principal: PrincipalUser,
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
IdenNProvider: identNProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPrincipalServiceAccountIdentity(serviceAccountID valuer.UUID, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
|
||||
return &Identity{
|
||||
ServiceAccountID: serviceAccountID,
|
||||
Principal: PrincipalServiceAccount,
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
IdenNProvider: identNProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (typ Identity) MarshalBinary() ([]byte, error) { return json.Marshal(typ) }
|
||||
|
||||
func (typ *Identity) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, typ) }
|
||||
|
||||
func (typ *Identity) ToClaims() Claims {
|
||||
return Claims{
|
||||
UserID: typ.UserID.String(),
|
||||
ServiceAccountID: typ.ServiceAccountID.String(),
|
||||
Principal: typ.Principal,
|
||||
Email: typ.Email.String(),
|
||||
OrgID: typ.OrgID.String(),
|
||||
IdentNProvider: typ.IdenNProvider,
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import "github.com/hanzoai/o11y/pkg/valuer"
|
||||
|
||||
var (
|
||||
IdentNProviderIAM = IdentNProvider{valuer.NewString("iam")}
|
||||
IdentNProviderTokenizer = IdentNProvider{valuer.NewString("tokenizer")}
|
||||
IdentNProviderAPIKey = IdentNProvider{valuer.NewString("api_key")}
|
||||
IdentNProviderAnonymous = IdentNProvider{valuer.NewString("anonymous")}
|
||||
IdentNProviderInternal = IdentNProvider{valuer.NewString("internal")}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type AttributeMapping struct {
|
||||
// Key which contains the email in the claim/token/attributes map. Defaults to "email"
|
||||
Email string `json:"email"`
|
||||
|
||||
// Key which contains the name in the claim/token/attributes map. Defaults to "name"
|
||||
Name string `json:"name"`
|
||||
|
||||
// Key which contains the groups in the claim/token/attributes map. Defaults to "groups"
|
||||
Groups string `json:"groups"`
|
||||
|
||||
// Key which contains the role in the claim/token/attributes map. Defaults to "role"
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (attr *AttributeMapping) UnmarshalJSON(data []byte) error {
|
||||
type Alias AttributeMapping
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.Email == "" {
|
||||
temp.Email = "email"
|
||||
}
|
||||
|
||||
if temp.Name == "" {
|
||||
temp.Name = "name"
|
||||
}
|
||||
|
||||
if temp.Groups == "" {
|
||||
temp.Groups = "groups"
|
||||
}
|
||||
|
||||
if temp.Role == "" {
|
||||
temp.Role = "role"
|
||||
}
|
||||
|
||||
*attr = AttributeMapping(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
type RoleMapping struct {
|
||||
// Default role assigned to new SSO users when no group mapping applies.
|
||||
DefaultRole string `json:"defaultRole"`
|
||||
|
||||
// Map of IDP group name to O11y role name.
|
||||
GroupMappings map[string]string `json:"groupMappings"`
|
||||
|
||||
// If true, use the role claim directly from IDP instead of group mappings.
|
||||
UseRoleAttribute bool `json:"useRoleAttribute"`
|
||||
}
|
||||
|
||||
func (roleMapping *RoleMapping) UnmarshalJSON(data []byte) error {
|
||||
type alias RoleMapping
|
||||
|
||||
var temp alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
temp.DefaultRole = NormalizeRoleName(temp.DefaultRole)
|
||||
for group, role := range temp.GroupMappings {
|
||||
temp.GroupMappings[group] = NormalizeRoleName(role)
|
||||
}
|
||||
|
||||
*roleMapping = RoleMapping(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (roleMapping *RoleMapping) NewRolesFromCallbackIdentity(callbackIdentity *CallbackIdentity, roleAttributeExists bool) []string {
|
||||
if roleMapping == nil {
|
||||
return []string{O11yViewerRoleName}
|
||||
}
|
||||
|
||||
if roleAttributeExists {
|
||||
return []string{NormalizeRoleName(callbackIdentity.Role)}
|
||||
}
|
||||
|
||||
if len(roleMapping.GroupMappings) > 0 && len(callbackIdentity.Groups) > 0 {
|
||||
roleNames := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, group := range callbackIdentity.Groups {
|
||||
roleName, exists := roleMapping.GroupMappings[group]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[roleName]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[roleName] = struct{}{}
|
||||
roleNames = append(roleNames, roleName)
|
||||
}
|
||||
if len(roleNames) > 0 {
|
||||
return roleNames
|
||||
}
|
||||
}
|
||||
|
||||
return []string{roleMapping.DefaultRoleName()}
|
||||
}
|
||||
|
||||
func (roleMapping *RoleMapping) DefaultRoleName() string {
|
||||
if roleMapping.DefaultRole != "" {
|
||||
return roleMapping.DefaultRole
|
||||
}
|
||||
|
||||
return O11yViewerRoleName
|
||||
}
|
||||
|
||||
func (roleMapping *RoleMapping) RoleNames() []string {
|
||||
if roleMapping == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
roleNames := make([]string, 0, len(roleMapping.GroupMappings)+1)
|
||||
|
||||
if roleMapping.DefaultRole != "" {
|
||||
seen[roleMapping.DefaultRole] = struct{}{}
|
||||
roleNames = append(roleNames, roleMapping.DefaultRole)
|
||||
}
|
||||
|
||||
for _, roleName := range roleMapping.GroupMappings {
|
||||
if roleName == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[roleName]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[roleName] = struct{}{}
|
||||
roleNames = append(roleNames, roleName)
|
||||
}
|
||||
|
||||
return roleNames
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
)
|
||||
|
||||
type OIDCConfig struct {
|
||||
// It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".
|
||||
Issuer string `json:"issuer"`
|
||||
|
||||
// Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail
|
||||
// This provides a way to override the Issuer url from the .well-known/openid-configuration issuer
|
||||
// from the .well-known/openid-configuration issuer
|
||||
IssuerAlias string `json:"issuerAlias"`
|
||||
|
||||
// It is the application's ID.
|
||||
ClientID string `json:"clientId"`
|
||||
|
||||
// It is the application's secret.
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
|
||||
// Mapping of claims to the corresponding fields in the token.
|
||||
ClaimMapping AttributeMapping `json:"claimMapping"`
|
||||
|
||||
// Whether to skip email verification. Defaults to "false"
|
||||
InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"`
|
||||
|
||||
// Uses the userinfo endpoint to get additional claims for the token. This is especially useful where upstreams return "thin" id tokens
|
||||
GetUserInfo bool `json:"getUserInfo"`
|
||||
}
|
||||
|
||||
func (config *OIDCConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias OIDCConfig
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.Issuer == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "issuer is required")
|
||||
}
|
||||
|
||||
if temp.ClientID == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "clientId is required")
|
||||
}
|
||||
|
||||
if temp.ClientSecret == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "clientSecret is required")
|
||||
}
|
||||
|
||||
if temp.ClaimMapping == (AttributeMapping{}) {
|
||||
if err := json.Unmarshal([]byte("{}"), &temp.ClaimMapping); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
*config = OIDCConfig(temp)
|
||||
return nil
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
)
|
||||
|
||||
type SamlConfig struct {
|
||||
// The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">
|
||||
SamlEntity string `json:"samlEntity"`
|
||||
|
||||
// The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>
|
||||
SamlIdp string `json:"samlIdp"`
|
||||
|
||||
// The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>
|
||||
SamlCert string `json:"samlCert"`
|
||||
|
||||
// Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
// For providers like jumpcloud, this should be set to true.
|
||||
// Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.
|
||||
InsecureSkipAuthNRequestsSigned bool `json:"insecureSkipAuthNRequestsSigned"`
|
||||
|
||||
// Mapping of SAML assertion attributes
|
||||
AttributeMapping AttributeMapping `json:"attributeMapping"`
|
||||
}
|
||||
|
||||
func (config *SamlConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias SamlConfig
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.SamlEntity == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlEntity is required")
|
||||
}
|
||||
|
||||
if temp.SamlIdp == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlIdp is required")
|
||||
}
|
||||
|
||||
if temp.SamlCert == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlCert is required")
|
||||
}
|
||||
|
||||
if temp.AttributeMapping == (AttributeMapping{}) {
|
||||
if err := json.Unmarshal([]byte("{}"), &temp.AttributeMapping); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
*config = SamlConfig(temp)
|
||||
return nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
type SessionContext struct {
|
||||
Exists bool `json:"exists"`
|
||||
Orgs []*OrgSessionContext `json:"orgs"`
|
||||
}
|
||||
|
||||
type OrgSessionContext struct {
|
||||
ID valuer.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AuthNSupport AuthNSupport `json:"authNSupport"`
|
||||
Warning *errors.JSON `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
type AuthNSupport struct {
|
||||
Callback []CallbackAuthNSupport `json:"callback"`
|
||||
Password []PasswordAuthNSupport `json:"password"`
|
||||
}
|
||||
|
||||
type CallbackAuthNSupport struct {
|
||||
Provider AuthNProvider `json:"provider"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type PasswordAuthNSupport struct {
|
||||
Provider AuthNProvider `json:"provider"`
|
||||
}
|
||||
|
||||
func NewSessionContext() *SessionContext {
|
||||
return &SessionContext{Exists: false, Orgs: []*OrgSessionContext{}}
|
||||
}
|
||||
|
||||
func NewOrgSessionContext(orgID valuer.UUID, name string) *OrgSessionContext {
|
||||
return &OrgSessionContext{
|
||||
ID: orgID,
|
||||
Name: name,
|
||||
AuthNSupport: AuthNSupport{
|
||||
Password: []PasswordAuthNSupport{},
|
||||
Callback: []CallbackAuthNSupport{},
|
||||
},
|
||||
Warning: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionContext) AddOrgContext(orgContext *OrgSessionContext) *SessionContext {
|
||||
s.Orgs = append(s.Orgs, orgContext)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *OrgSessionContext) AddPasswordAuthNSupport(provider AuthNProvider) *OrgSessionContext {
|
||||
s.AuthNSupport.Password = append(s.AuthNSupport.Password, PasswordAuthNSupport{Provider: provider})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *OrgSessionContext) AddCallbackAuthNSupport(provider AuthNProvider, url string) *OrgSessionContext {
|
||||
s.AuthNSupport.Callback = append(s.AuthNSupport.Callback, CallbackAuthNSupport{Provider: provider, URL: url})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *OrgSessionContext) AddWarning(warning error) *OrgSessionContext {
|
||||
s.Warning = errors.AsJSON(warning)
|
||||
return s
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/types/cachetypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/sethvargo/go-password/password"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeTokenRotationRequired = errors.MustNewCode("token_rotation_required")
|
||||
ErrCodeTokenExpired = errors.MustNewCode("token_expired")
|
||||
ErrCodeTokenNotFound = errors.MustNewCode("token_not_found")
|
||||
ErrCodeTokenOlderLastObservedAt = errors.MustNewCode("token_older_last_observed_at")
|
||||
)
|
||||
|
||||
var _ cachetypes.Cacheable = (*Token)(nil)
|
||||
|
||||
type PostableRotateToken struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
type StorableToken = Token
|
||||
|
||||
type GettableToken struct {
|
||||
TokenType string `json:"tokenType"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
bun.BaseModel `bun:"table:auth_token,alias:auth_token"`
|
||||
|
||||
ID valuer.UUID `bun:"id,pk,type:text"`
|
||||
Meta map[string]string `bun:"meta,notnull"`
|
||||
PrevAccessToken string `bun:"prev_access_token,nullzero"`
|
||||
AccessToken string `bun:"access_token,notnull"`
|
||||
PrevRefreshToken string `bun:"prev_refresh_token,nullzero"`
|
||||
RefreshToken string `bun:"refresh_token,notnull"`
|
||||
LastObservedAt time.Time `bun:"last_observed_at,nullzero"`
|
||||
RotatedAt time.Time `bun:"rotated_at,nullzero"`
|
||||
CreatedAt time.Time `bun:"created_at,notnull"`
|
||||
UpdatedAt time.Time `bun:"updated_at,notnull"`
|
||||
UserID valuer.UUID `bun:"user_id,notnull"`
|
||||
}
|
||||
|
||||
func NewToken(meta map[string]string, userID valuer.UUID) (*Token, error) {
|
||||
accessToken := password.MustGenerate(32, 10, 0, true, true)
|
||||
refreshToken := password.MustGenerate(32, 12, 0, true, true)
|
||||
|
||||
return &Token{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Meta: meta,
|
||||
PrevAccessToken: "",
|
||||
AccessToken: accessToken,
|
||||
PrevRefreshToken: "",
|
||||
RefreshToken: refreshToken,
|
||||
LastObservedAt: time.Time{},
|
||||
RotatedAt: time.Time{},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
UserID: userID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewTokenFromAccessTokenAndRefreshToken(accessToken string, refreshToken string, meta map[string]string, userID valuer.UUID) (*Token, error) {
|
||||
return &Token{
|
||||
ID: valuer.UUID{},
|
||||
Meta: meta,
|
||||
PrevAccessToken: "",
|
||||
AccessToken: accessToken,
|
||||
PrevRefreshToken: "",
|
||||
RefreshToken: refreshToken,
|
||||
LastObservedAt: time.Time{},
|
||||
RotatedAt: time.Time{},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
UserID: userID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewGettableTokenFromToken(token *Token, rotationInterval time.Duration) *GettableToken {
|
||||
return &GettableToken{
|
||||
TokenType: "bearer",
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
ExpiresIn: int(time.Until(token.RotationAt(rotationInterval)).Seconds()),
|
||||
}
|
||||
}
|
||||
|
||||
func NewURLValuesFromToken(token *Token, rotationInterval time.Duration) url.Values {
|
||||
return url.Values{
|
||||
"tokenType": {"bearer"},
|
||||
"accessToken": {token.AccessToken},
|
||||
"refreshToken": {token.RefreshToken},
|
||||
"expiresIn": {strconv.Itoa(int(time.Until(token.RotationAt(rotationInterval)).Seconds()))},
|
||||
}
|
||||
}
|
||||
|
||||
func (typ *PostableRotateToken) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableRotateToken
|
||||
var temp Alias
|
||||
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.RefreshToken == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "refresh token is required")
|
||||
}
|
||||
|
||||
*typ = PostableRotateToken(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ *Token) IsValid(rotationInterval time.Duration, idleDuration time.Duration, maxDuration time.Duration) error {
|
||||
// Check for expiration
|
||||
if err := typ.IsExpired(idleDuration, maxDuration); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for rotation
|
||||
if err := typ.IsRotationRequired(rotationInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ *Token) IsExpired(idleDuration time.Duration, maxDuration time.Duration) error {
|
||||
// If now - last_seen_at > idle_duration, the token will be considered as expired.
|
||||
if !typ.LastObservedAt.IsZero() && typ.LastObservedAt.Before(time.Now().Add(-idleDuration)) {
|
||||
return errors.New(errors.TypeUnauthenticated, ErrCodeTokenExpired, "token has not been used for too long")
|
||||
}
|
||||
|
||||
// If now - created_at > max_duration, the token will be considered as expired.
|
||||
if typ.CreatedAt.Before(time.Now().Add(-maxDuration)) {
|
||||
return errors.New(errors.TypeUnauthenticated, ErrCodeTokenExpired, "token was created a long time ago")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ *Token) IsRotationRequired(rotationInterval time.Duration) error {
|
||||
if !typ.RotatedAt.IsZero() && typ.RotatedAt.Before(time.Now().Add(-rotationInterval)) {
|
||||
return errors.New(errors.TypeUnauthenticated, ErrCodeTokenRotationRequired, "token needs to be rotated")
|
||||
}
|
||||
|
||||
if typ.RotatedAt.IsZero() && typ.CreatedAt.Before(time.Now().Add(-rotationInterval)) {
|
||||
return errors.New(errors.TypeUnauthenticated, ErrCodeTokenRotationRequired, "token needs to be rotated")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ *Token) Rotate(accessTokenOrPrevAccessToken string, refreshTokenOrPrevRefreshToken string, rotationDuration time.Duration, idleDuration time.Duration, maxDuration time.Duration) error {
|
||||
if typ.PrevAccessToken == accessTokenOrPrevAccessToken && typ.PrevRefreshToken == refreshTokenOrPrevRefreshToken {
|
||||
// If the token has been rotated within the rotation duration, do nothing and return the same token.
|
||||
if !typ.RotatedAt.IsZero() && typ.RotatedAt.Before(time.Now().Add(-rotationDuration)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "invalid token pair")
|
||||
}
|
||||
|
||||
if typ.AccessToken != accessTokenOrPrevAccessToken || typ.RefreshToken != refreshTokenOrPrevRefreshToken {
|
||||
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "invalid token pair")
|
||||
}
|
||||
|
||||
if err := typ.IsExpired(idleDuration, maxDuration); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate new access and refresh tokens.
|
||||
typ.PrevAccessToken = typ.AccessToken
|
||||
typ.AccessToken = password.MustGenerate(32, 10, 0, true, true)
|
||||
typ.PrevRefreshToken = typ.RefreshToken
|
||||
typ.RefreshToken = password.MustGenerate(32, 12, 0, true, true)
|
||||
|
||||
// Set the rotated at time.
|
||||
typ.RotatedAt = time.Now()
|
||||
|
||||
// Set the updated at time.
|
||||
typ.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ *Token) RotationAt(rotationInterval time.Duration) time.Time {
|
||||
if typ.RotatedAt.IsZero() {
|
||||
return typ.CreatedAt.Add(rotationInterval)
|
||||
}
|
||||
|
||||
return typ.RotatedAt.Add(rotationInterval)
|
||||
}
|
||||
|
||||
func (typ *Token) UpdateLastObservedAt(lastObservedAt time.Time) error {
|
||||
if lastObservedAt.Before(typ.LastObservedAt) {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeTokenOlderLastObservedAt, "last observed at is before the current last observed at")
|
||||
}
|
||||
|
||||
typ.LastObservedAt = lastObservedAt
|
||||
typ.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ Token) MarshalBinary() ([]byte, error) {
|
||||
return json.Marshal(typ)
|
||||
}
|
||||
|
||||
func (typ *Token) UnmarshalBinary(data []byte) error {
|
||||
return json.Unmarshal(data, typ)
|
||||
}
|
||||
|
||||
type TokenStore interface {
|
||||
// Create a new token.
|
||||
Create(context.Context, *StorableToken) error
|
||||
|
||||
// Get an identity by userID.
|
||||
GetIdentityByUserID(context.Context, valuer.UUID) (*Identity, error)
|
||||
|
||||
// Get a token by AccessToken.
|
||||
GetByAccessToken(context.Context, string) (*StorableToken, error)
|
||||
|
||||
// Updates or doesn't update a token by access token or previous access token with update. The callback is run in a transaction.
|
||||
GetOrUpdateByAccessTokenOrPrevAccessToken(context.Context, string, func(context.Context, *StorableToken) error) error
|
||||
|
||||
// Get a token by userID and refresh token.
|
||||
GetByUserIDAndRefreshToken(context.Context, valuer.UUID, string) (*StorableToken, error)
|
||||
|
||||
// List all tokens by orgIDs.
|
||||
ListByOrgIDs(context.Context, []valuer.UUID) ([]*StorableToken, error)
|
||||
|
||||
// List all tokens by orgID.
|
||||
ListByOrgID(context.Context, valuer.UUID) ([]*StorableToken, error)
|
||||
|
||||
// List all tokens by userID.
|
||||
ListByUserID(context.Context, valuer.UUID) ([]*StorableToken, error)
|
||||
|
||||
// Update a token.
|
||||
Update(context.Context, *StorableToken) error
|
||||
|
||||
// Delete a token by access token.
|
||||
DeleteByAccessToken(context.Context, string) error
|
||||
|
||||
// Delete many tokens by IDs.
|
||||
DeleteMany(context.Context, []valuer.UUID) error
|
||||
|
||||
// Delete a token by userID.
|
||||
DeleteByUserID(context.Context, valuer.UUID) error
|
||||
|
||||
// Update last observed at by access token.
|
||||
UpdateLastObservedAtByAccessToken(context.Context, []map[string]any) error
|
||||
}
|
||||
@@ -12,12 +12,11 @@ import (
|
||||
var (
|
||||
// Templates is a list of all the templates that are supported by the emailing service.
|
||||
// This list should be updated whenever a new template is added.
|
||||
Templates = []TemplateName{TemplateNameInvitationEmail, TemplateNameResetPassword}
|
||||
Templates = []TemplateName{TemplateNameInvitationEmail}
|
||||
)
|
||||
|
||||
var (
|
||||
TemplateNameInvitationEmail = TemplateName{valuer.NewString("invitation")}
|
||||
TemplateNameResetPassword = TemplateName{valuer.NewString("reset_password")}
|
||||
TemplateNameAPIKeyEvent = TemplateName{valuer.NewString("api_key_event")}
|
||||
)
|
||||
|
||||
@@ -27,8 +26,6 @@ func NewTemplateName(name string) (TemplateName, error) {
|
||||
switch name {
|
||||
case TemplateNameInvitationEmail.StringValue():
|
||||
return TemplateNameInvitationEmail, nil
|
||||
case TemplateNameResetPassword.StringValue():
|
||||
return TemplateNameResetPassword, nil
|
||||
case TemplateNameAPIKeyEvent.StringValue():
|
||||
return TemplateNameAPIKeyEvent, nil
|
||||
default:
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/sethvargo/go-password/password"
|
||||
"github.com/uptrace/bun"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
symbols []rune = []rune("~!@#$%^&*()_+`-={}|[]\\:\"<>?,./")
|
||||
minPasswordLength int = 12
|
||||
ErrInvalidPassword = errors.Newf(errors.TypeInvalidInput, errors.MustNewCode("invalid_password"), "password must be at least %d characters long, should contain at least one uppercase letter [A-Z], one lowercase letter [a-z], one number [0-9], and one symbol [%c].", minPasswordLength, symbols)
|
||||
ErrCodeResetPasswordTokenAlreadyExists = errors.MustNewCode("reset_password_token_already_exists")
|
||||
ErrCodePasswordNotFound = errors.MustNewCode("password_not_found")
|
||||
ErrCodeResetPasswordTokenNotFound = errors.MustNewCode("reset_password_token_not_found")
|
||||
ErrCodeResetPasswordTokenExpired = errors.MustNewCode("reset_password_token_expired")
|
||||
ErrCodePasswordAlreadyExists = errors.MustNewCode("password_already_exists")
|
||||
ErrCodeIncorrectPassword = errors.MustNewCode("incorrect_password")
|
||||
)
|
||||
|
||||
type PostableVerifyResetPasswordToken struct {
|
||||
Token string `json:"token" required:"true"`
|
||||
}
|
||||
|
||||
type PostableResetPassword struct {
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type PostableForgotPassword struct {
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Email valuer.Email `json:"email" required:"true"`
|
||||
FrontendBaseURL string `json:"frontendBaseURL"`
|
||||
}
|
||||
|
||||
type ResetPasswordToken struct {
|
||||
bun.BaseModel `bun:"table:reset_password_token"`
|
||||
|
||||
Identifiable
|
||||
Token string `bun:"token,type:text,notnull" json:"token"`
|
||||
PasswordID valuer.UUID `bun:"password_id,type:text,notnull,unique" json:"passwordId"`
|
||||
ExpiresAt time.Time `bun:"expires_at,type:timestamptz,nullzero" json:"expiresAt"`
|
||||
}
|
||||
|
||||
type FactorPassword struct {
|
||||
bun.BaseModel `bun:"table:factor_password"`
|
||||
|
||||
Identifiable
|
||||
Password string `bun:"password,type:text,notnull" json:"password"`
|
||||
Temporary bool `bun:"temporary,type:boolean,notnull" json:"temporary"`
|
||||
UserID string `bun:"user_id,type:text,notnull,unique" json:"userId"`
|
||||
TimeAuditable
|
||||
}
|
||||
|
||||
func (request *ChangePasswordRequest) UnmarshalJSON(data []byte) error {
|
||||
type Alias ChangePasswordRequest
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !IsPasswordValid(temp.NewPassword) {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
|
||||
*request = ChangePasswordRequest(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (request *PostableResetPassword) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableResetPassword
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !IsPasswordValid(temp.Password) {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
|
||||
*request = PostableResetPassword(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFactorPassword(password string, userID string) (*FactorPassword, error) {
|
||||
if !IsPasswordValid(password) {
|
||||
return nil, ErrInvalidPassword
|
||||
}
|
||||
|
||||
hashedPassword, err := NewHashedPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FactorPassword{
|
||||
Identifiable: Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Password: string(hashedPassword),
|
||||
Temporary: false,
|
||||
UserID: userID,
|
||||
TimeAuditable: TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GenerateFactorPassword(userID string) (*FactorPassword, error) {
|
||||
password, err := password.Generate(12, 1, 1, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewFactorPassword(password+"Z", userID)
|
||||
}
|
||||
|
||||
func MustGenerateFactorPassword(userID string) *FactorPassword {
|
||||
password, err := GenerateFactorPassword(userID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return password
|
||||
}
|
||||
|
||||
func NewHashedPassword(password string) (string, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
func NewResetPasswordToken(passwordID valuer.UUID, expiresAt time.Time) (*ResetPasswordToken, error) {
|
||||
return &ResetPasswordToken{
|
||||
Identifiable: Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Token: valuer.GenerateUUID().String(),
|
||||
PasswordID: passwordID,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func IsPasswordValid(password string) bool {
|
||||
if len(password) < minPasswordLength {
|
||||
return false
|
||||
}
|
||||
|
||||
hasUpperCase := false
|
||||
hasLowerCase := false
|
||||
hasNumber := false
|
||||
hasSymbol := false
|
||||
|
||||
for _, char := range password {
|
||||
if !hasLowerCase && unicode.IsLower(char) {
|
||||
hasLowerCase = true
|
||||
}
|
||||
|
||||
if !hasUpperCase && unicode.IsUpper(char) {
|
||||
hasUpperCase = true
|
||||
}
|
||||
|
||||
if !hasNumber && unicode.IsNumber(char) {
|
||||
hasNumber = true
|
||||
}
|
||||
|
||||
if !hasSymbol && slices.Contains(symbols, char) {
|
||||
hasSymbol = true
|
||||
}
|
||||
|
||||
if !unicode.IsLetter(char) && !unicode.IsNumber(char) && !slices.Contains(symbols, char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpperCase || !hasLowerCase || !hasNumber || !hasSymbol {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (f *FactorPassword) Update(password string) error {
|
||||
if !IsPasswordValid(password) {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
|
||||
hashedPassword, err := NewHashedPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.Password = hashedPassword
|
||||
f.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FactorPassword) Equals(password string) bool {
|
||||
return comparePassword(f.Password, password)
|
||||
}
|
||||
|
||||
func comparePassword(hashedPassword string, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func (r *ResetPasswordToken) IsExpired() bool {
|
||||
return r.ExpiresAt.Before(time.Now())
|
||||
}
|
||||
|
||||
func (r *ResetPasswordToken) FactorPasswordResetLink(frontendBaseUrl string) string {
|
||||
return fmt.Sprintf("%s/password-reset?token=%s", frontendBaseUrl, r.Token)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMustGenerateFactorPassword(t *testing.T) {
|
||||
assert.NotPanics(t, func() {
|
||||
MustGenerateFactorPassword(valuer.GenerateUUID().String())
|
||||
})
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
package globaltypes
|
||||
|
||||
// IdentNConfig is the part of GET /v1/o11y/global/config the console reads to
|
||||
// decide how identity reaches it. The `tokenizer` member is gone with the
|
||||
// tokenizer: o11y minted its own access and refresh tokens, and it does not.
|
||||
type IdentNConfig struct {
|
||||
Tokenizer TokenizerConfig `json:"tokenizer"`
|
||||
APIKey APIKeyConfig `json:"apikey"`
|
||||
Impersonation ImpersonationConfig `json:"impersonation"`
|
||||
}
|
||||
|
||||
type TokenizerConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type APIKeyConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
@@ -18,9 +16,8 @@ type ImpersonationConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func NewIdentNConfig(tokenizer TokenizerConfig, apiKey APIKeyConfig, impersonation ImpersonationConfig) IdentNConfig {
|
||||
func NewIdentNConfig(apiKey APIKeyConfig, impersonation ImpersonationConfig) IdentNConfig {
|
||||
return IdentNConfig{
|
||||
Tokenizer: tokenizer,
|
||||
APIKey: apiKey,
|
||||
Impersonation: impersonation,
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInviteAlreadyExists = errors.MustNewCode("invite_already_exists")
|
||||
ErrInviteNotFound = errors.MustNewCode("invite_not_found")
|
||||
)
|
||||
|
||||
type GettableInvite = Invite
|
||||
|
||||
type Invite struct {
|
||||
bun.BaseModel `bun:"table:user_invite"`
|
||||
|
||||
Identifiable
|
||||
TimeAuditable
|
||||
Name string `bun:"name,type:text" json:"name"`
|
||||
Email valuer.Email `bun:"email,type:text" json:"email"`
|
||||
Token string `bun:"token,type:text" json:"token"`
|
||||
Role Role `bun:"role,type:text" json:"role"`
|
||||
OrgID valuer.UUID `bun:"org_id,type:text" json:"orgId"`
|
||||
|
||||
InviteLink string `bun:"-" json:"inviteLink"`
|
||||
}
|
||||
|
||||
type PostableInvite struct {
|
||||
Name string `json:"name"`
|
||||
Email valuer.Email `json:"email"`
|
||||
Role Role `json:"role"`
|
||||
FrontendBaseUrl string `json:"frontendBaseUrl"`
|
||||
}
|
||||
|
||||
type PostableBulkInviteRequest struct {
|
||||
Invites []PostableInvite `json:"invites" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func (request *PostableBulkInviteRequest) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableBulkInviteRequest
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check for duplicate emails in the same request
|
||||
seen := make(map[string]struct{}, len(temp.Invites))
|
||||
for _, invite := range temp.Invites {
|
||||
email := invite.Email.StringValue()
|
||||
if _, exists := seen[email]; exists {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "Duplicate email in request: %s", email)
|
||||
}
|
||||
seen[email] = struct{}{}
|
||||
}
|
||||
|
||||
*request = PostableBulkInviteRequest(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewInvite(name string, role Role, orgID valuer.UUID, email valuer.Email) (*Invite, error) {
|
||||
invite := &Invite{
|
||||
Identifiable: Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Name: name,
|
||||
Email: email,
|
||||
Token: valuer.GenerateUUID().String(),
|
||||
Role: role,
|
||||
OrgID: orgID,
|
||||
TimeAuditable: TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
return invite, nil
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
@@ -59,14 +58,6 @@ type PostableRole struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
}
|
||||
|
||||
type PostableRegisterOrgAndAdmin struct {
|
||||
Name string `json:"name"`
|
||||
Email valuer.Email `json:"email"`
|
||||
Password string `json:"password"`
|
||||
OrgDisplayName string `json:"orgDisplayName"`
|
||||
OrgName string `json:"orgName"`
|
||||
}
|
||||
|
||||
func NewUser(displayName string, email valuer.Email, orgID valuer.UUID, status valuer.String) (*User, error) {
|
||||
if email.IsZero() {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
|
||||
@@ -267,68 +258,22 @@ func NewTraitsFromDeprecatedUser(user *DeprecatedUser) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func (request *PostableRegisterOrgAndAdmin) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableRegisterOrgAndAdmin
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !IsPasswordValid(temp.Password) {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
|
||||
*request = PostableRegisterOrgAndAdmin(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserStore interface {
|
||||
// Creates a user.
|
||||
CreateUser(ctx context.Context, user *User) error
|
||||
|
||||
// Get user by id.
|
||||
GetUser(context.Context, valuer.UUID) (*User, error)
|
||||
|
||||
// Get user by orgID and id.
|
||||
GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*User, error)
|
||||
|
||||
// Get user by email and orgID.
|
||||
GetNonDeletedUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*User, error)
|
||||
|
||||
// List users by org.
|
||||
ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*User, error)
|
||||
|
||||
// List users by email and org ids.
|
||||
ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*User, error)
|
||||
|
||||
// Get users for an org id using emails and statuses
|
||||
GetUsersByEmailsOrgIDAndStatuses(context.Context, valuer.UUID, []string, []string) ([]*User, error)
|
||||
|
||||
UpdateUser(ctx context.Context, orgID valuer.UUID, user *User) error
|
||||
DeleteUser(ctx context.Context, orgID string, id string) error
|
||||
SoftDeleteUser(ctx context.Context, orgID string, id string) error
|
||||
|
||||
// Creates a password.
|
||||
CreatePassword(ctx context.Context, password *FactorPassword) error
|
||||
CreateResetPasswordToken(ctx context.Context, resetPasswordRequest *ResetPasswordToken) error
|
||||
GetPassword(ctx context.Context, id valuer.UUID) (*FactorPassword, error)
|
||||
GetPasswordByUserID(ctx context.Context, userID valuer.UUID) (*FactorPassword, error)
|
||||
GetResetPasswordToken(ctx context.Context, token string) (*ResetPasswordToken, error)
|
||||
GetResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) (*ResetPasswordToken, error)
|
||||
GetResetPasswordTokenByOrgIDAndUserID(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) (*ResetPasswordToken, error)
|
||||
DeleteResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) error
|
||||
UpdatePassword(ctx context.Context, password *FactorPassword) error
|
||||
|
||||
CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
|
||||
CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error)
|
||||
|
||||
// Get root user by org.
|
||||
GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*User, error)
|
||||
|
||||
// Get user by reset password token
|
||||
GetUserByResetPasswordToken(ctx context.Context, token string) (*User, error)
|
||||
|
||||
// Get users having role by org id and role id
|
||||
GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*User, error)
|
||||
|
||||
|
||||
-35
@@ -57,7 +57,6 @@ func mountPlatform(app *zip.App) {
|
||||
opGet(g, "/version", version)
|
||||
opGet(g, "/health", health)
|
||||
opGet(g, "/disks", disks)
|
||||
opPost(g, "/register", register)
|
||||
opPost(g, "/span_percentile", spanPercentile)
|
||||
opPost(g, "/query_filter/analyze", analyzeQueryFilter)
|
||||
opGet(g, "/filter_suggestions", filterSuggestions)
|
||||
@@ -175,16 +174,6 @@ func disks(ctx context.Context, _ *struct{}) (*O11yDiskList, error) {
|
||||
return out, relay(ctx, nil, nil, out)
|
||||
}
|
||||
|
||||
// register creates the FIRST organization and its admin user. It is open by
|
||||
// design — there is nobody to be signed in as yet — and refuses once setup has
|
||||
// completed, after which new users arrive by invitation only.
|
||||
//
|
||||
// Open by design; the runtime's own gate is OpenAccess.
|
||||
func register(ctx context.Context, in *O11yRegisterIn) (*O11yRegisterOut, error) {
|
||||
out := new(O11yRegisterOut)
|
||||
return out, relay(ctx, nil, in, out)
|
||||
}
|
||||
|
||||
// spanPercentile places one span's duration among its peers: the p50/p90/p99
|
||||
// durations of like spans, and the percentile the given duration lands at.
|
||||
//
|
||||
@@ -422,20 +411,6 @@ type O11yHealthIn struct {
|
||||
Live bool `json:"live,omitempty"`
|
||||
}
|
||||
|
||||
// O11yRegisterIn creates the first organization and its admin user.
|
||||
type O11yRegisterIn struct {
|
||||
// Name is the admin's display name.
|
||||
Name string `json:"name,omitempty"`
|
||||
// Email is the admin's email. Required.
|
||||
Email string `json:"email" validate:"required"`
|
||||
// Password is the admin's password.
|
||||
Password string `json:"password,omitempty"`
|
||||
// OrgDisplayName is the organization's display name.
|
||||
OrgDisplayName string `json:"orgDisplayName,omitempty"`
|
||||
// OrgName is the organization's name.
|
||||
OrgName string `json:"orgName,omitempty"`
|
||||
}
|
||||
|
||||
// O11ySpanPercentileIn names one span and the peer group to place it in.
|
||||
type O11ySpanPercentileIn struct {
|
||||
// SpanDuration is the span's duration in nanoseconds.
|
||||
@@ -634,16 +609,6 @@ type O11yDisk struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// O11yRegisterOut is the created admin user.
|
||||
type O11yRegisterOut struct {
|
||||
// Status is "success".
|
||||
Status string `json:"status"`
|
||||
// Data is the user. The runtime answers register with the same user shape
|
||||
// the identity face reads, so it is the ONE O11yUser (identity.go) — a
|
||||
// created user is a user, and the document names it once.
|
||||
Data O11yUser `json:"data"`
|
||||
}
|
||||
|
||||
// O11ySpanPercentileOut is a span's percentile placement.
|
||||
type O11ySpanPercentileOut struct {
|
||||
// Status is "success".
|
||||
|
||||
+9
-12
@@ -57,11 +57,11 @@ func assertRoutes(t *testing.T, want map[string]bool, prefix string) {
|
||||
}
|
||||
}
|
||||
|
||||
// THE WHOLE SURFACE, counted. 366 is the number of method+path pairs the o11y
|
||||
// THE WHOLE SURFACE, counted. 329 is the number of method+path pairs the o11y
|
||||
// runtime registers on its gorilla/mux tree (pkg/query-service/app/routes_*.go
|
||||
// and pkg/apiserver/o11yapiserver/*.go). Mount registers the same 366 — 353 as
|
||||
// and pkg/apiserver/o11yapiserver/*.go). Mount registers the same 329 — 319 as
|
||||
// typed ops that carry a named In, a named Out and their prose into the
|
||||
// document, 10 as named escape hatches, 3 as native service probes.
|
||||
// document, 7 as named escape hatches, 3 as native service probes.
|
||||
//
|
||||
// If this number moves, one of two things happened and both need a human: a
|
||||
// route was added to the runtime and not named here (it would 404 in the
|
||||
@@ -72,8 +72,8 @@ func TestEveryRouteIsNamedAndCounted(t *testing.T) {
|
||||
all := registered(t, app)
|
||||
|
||||
const (
|
||||
wantTyped = 353 // typed ops: in the OpenAPI document and the MCP tool list
|
||||
wantHatches = 10 // mount.go mountHatches, each with its reason
|
||||
wantTyped = 319 // typed ops: in the OpenAPI document and the MCP tool list
|
||||
wantHatches = 7 // mount.go mountHatches, each with its reason
|
||||
wantProbes = 3 // health.go livez/healthz/readyz, native with fall-through
|
||||
)
|
||||
if len(all) != wantTyped+wantHatches+wantProbes {
|
||||
@@ -147,7 +147,7 @@ func setRuntime(t *testing.T, fn http.HandlerFunc) {
|
||||
t.Cleanup(func() { o11y.SetRuntime(nil) })
|
||||
}
|
||||
|
||||
// THE TEN. Each hatch is registered at its exact method and path, and each
|
||||
// THE SEVEN. Each hatch is registered at its exact method and path, and each
|
||||
// delegates with the path UNTOUCHED — a rewrite at this seam can only ever move
|
||||
// a request off its own route, and a mangler is invisible to the compiler, so
|
||||
// this is the only thing that catches one coming back.
|
||||
@@ -157,16 +157,13 @@ func TestHatchesDelegateVerbatim(t *testing.T) {
|
||||
{http.MethodGet, "/v1/o11y/query_progress"},
|
||||
{http.MethodPost, "/v1/o11y/export_raw_data"},
|
||||
|
||||
{http.MethodGet, "/v1/o11y/complete/google"},
|
||||
{http.MethodGet, "/v1/o11y/complete/oidc"},
|
||||
{http.MethodPost, "/v1/o11y/complete/saml"},
|
||||
|
||||
{http.MethodPost, "/v1/event/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"},
|
||||
{http.MethodPost, "/v1/event/6ba7b810-9dad-11d1-80b4-00c04fd430c8/store/"},
|
||||
{http.MethodPost, "/v1/o11y/api/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"},
|
||||
{http.MethodPost, "/v1/o11y/api/6ba7b810-9dad-11d1-80b4-00c04fd430c8/store/"},
|
||||
}
|
||||
if len(hatches) != 10 {
|
||||
if len(hatches) != 7 {
|
||||
t.Fatalf("the census itself is wrong: %d", len(hatches))
|
||||
}
|
||||
|
||||
@@ -234,8 +231,8 @@ func TestUnderIsStillLoadBearing(t *testing.T) {
|
||||
}
|
||||
declared++
|
||||
}
|
||||
if declared+undeclared != 353 {
|
||||
t.Fatalf("registry holds %d ops, want the 353 of TestEveryRouteIsNamedAndCounted", declared+undeclared)
|
||||
if declared+undeclared != 319 {
|
||||
t.Fatalf("registry holds %d ops, want the 319 of TestEveryRouteIsNamedAndCounted", declared+undeclared)
|
||||
}
|
||||
if undeclared == 0 {
|
||||
t.Fatalf("every one of the %d ops now declares its id — a declared id survives "+
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>{{.subject}}</title>
|
||||
</head>
|
||||
|
||||
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#333;background:#fff">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#fff">
|
||||
<tr>
|
||||
<td align="center" style="padding:0">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;width:100%">
|
||||
{{ if .format.Header.Enabled }}
|
||||
<tr>
|
||||
<td align="center" style="padding:16px 20px 16px">
|
||||
<img src="{{.format.Header.LogoURL}}" alt="O11y" width="160" height="40" style="display:block;border:0;outline:none;max-width:100%;height:auto">
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
<tr>
|
||||
<td style="padding:16px 20px 16px">
|
||||
<p style="margin:0 0 16px;font-size:16px;color:#333">
|
||||
Hi there,
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
|
||||
A password reset was requested for your O11y account.
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
|
||||
Click the button below to reset your password:
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:0 0 16px">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{.Link}}" target="_blank" style="display:inline-block;padding:16px 48px;font-size:16px;font-weight:600;color:#fff;background:#4E74F8;text-decoration:none;border-radius:4px">
|
||||
Reset Password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 4px;font-size:13px;color:#666;text-align:center">
|
||||
Button not working? Copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:13px;color:#4E74F8;word-break:break-all;text-align:center">
|
||||
<a href="{{.Link}}" style="color:#4E74F8;text-decoration:none">
|
||||
{{.Link}}
|
||||
</a>
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:0 0 16px">
|
||||
<tr>
|
||||
<td style="padding:16px;background:#fff4e6;border-radius:6px;border-left:4px solid #ff9800">
|
||||
<p style="margin:0;font-size:14px;color:#333;line-height:1.6">
|
||||
<strong>⏱️ This link will expire in {{.Expiry}}.</strong>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
|
||||
If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
{{ if .format.Help.Enabled }}
|
||||
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
|
||||
Need help? Chat with our team in the O11y application or email us at <a href="mailto:{{.format.Help.Email}}" style="color:#4E74F8;text-decoration:none">{{.format.Help.Email}}</a>.
|
||||
</p>
|
||||
{{ end }}
|
||||
<p style="margin:0;font-size:16px;color:#333;line-height:1.6">
|
||||
Thanks,<br><strong>The O11y Team</strong>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{ if .format.Footer.Enabled }}
|
||||
<tr>
|
||||
<td align="center" style="padding:8px 16px 8px">
|
||||
<p style="margin:0 0 8px;font-size:12px;color:#999;line-height:1.5">
|
||||
<a href="https://o11y.io/terms-of-service/" style="color:#4E74F8;text-decoration:none">Terms of Service</a> - <a href="https://o11y.io/privacy/" style="color:#4E74F8;text-decoration:none">Privacy Policy</a>
|
||||
</p>
|
||||
<p style="margin:0;font-size:12px;color:#999;line-height:1.5">
|
||||
© 2026 Hanzo AI, Inc.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,230 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
USER_EDITOR_EMAIL,
|
||||
USER_EDITOR_NAME,
|
||||
USER_EDITOR_PASSWORD,
|
||||
USER_VIEWER_EMAIL,
|
||||
assert_user_has_role,
|
||||
find_user_with_roles_by_email,
|
||||
)
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def test_register_with_invalid_input(o11y: types.O11y) -> None:
|
||||
"""
|
||||
Test the register endpoint with invalid input.
|
||||
1. Invalid Password
|
||||
2. Invalid Email
|
||||
"""
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/register"),
|
||||
json={
|
||||
"name": "admin",
|
||||
"orgId": "",
|
||||
"orgName": "integration.test",
|
||||
"email": "[email protected]",
|
||||
"password": "password", # invalid password
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/register"),
|
||||
json={
|
||||
"name": "admin",
|
||||
"orgId": "",
|
||||
"orgName": "integration.test",
|
||||
"email": "admin", # invalid email
|
||||
"password": "password123Z$",
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_register(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
response = requests.get(o11y.self.host_configs["8080"].get("/api/v1/version"), timeout=2)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["setupCompleted"] is False
|
||||
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/register"),
|
||||
json={
|
||||
"name": "admin",
|
||||
"orgId": "",
|
||||
"orgName": "integration.test",
|
||||
"email": USER_ADMIN_EMAIL,
|
||||
"password": USER_ADMIN_PASSWORD,
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
response = requests.get(o11y.self.host_configs["8080"].get("/api/v1/version"), timeout=2)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["setupCompleted"] is True
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Verify admin user exists via v2
|
||||
found_user = find_user_with_roles_by_email(o11y, admin_token, USER_ADMIN_EMAIL)
|
||||
assert_user_has_role(found_user, "o11y-admin")
|
||||
|
||||
|
||||
def test_invite(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
# Generate an invite token for the editor user
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": USER_EDITOR_EMAIL, "role": "EDITOR", "name": USER_EDITOR_NAME},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
invited_user = response.json()["data"]
|
||||
assert invited_user["email"] == USER_EDITOR_EMAIL
|
||||
assert invited_user["role"] == "EDITOR"
|
||||
|
||||
# Verify the user appears in the users list but as pending_invite status
|
||||
found_user = find_user_with_roles_by_email(o11y, admin_token, USER_EDITOR_EMAIL)
|
||||
assert found_user["status"] == "pending_invite"
|
||||
assert_user_has_role(found_user, "o11y-editor")
|
||||
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# Reset the password to complete the invite flow (activates the user and also grants authz)
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": USER_EDITOR_PASSWORD, "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Verify the user can now log in
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
assert editor_token is not None
|
||||
|
||||
# Verify that the editor user status has been updated to ACTIVE
|
||||
admin_token_fresh = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = find_user_with_roles_by_email(o11y, admin_token_fresh, USER_EDITOR_EMAIL)
|
||||
|
||||
assert_user_has_role(found_user, "o11y-editor")
|
||||
assert found_user["displayName"] == USER_EDITOR_NAME
|
||||
assert found_user["email"] == USER_EDITOR_EMAIL
|
||||
assert found_user["status"] == "active"
|
||||
|
||||
|
||||
def test_revoke_invite(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Invite the viewer user
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": USER_VIEWER_EMAIL, "role": "VIEWER"},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
invited_user = response.json()["data"]
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# Delete the pending invite user (revoke the invite)
|
||||
response = requests.delete(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v1/user/{invited_user['id']}"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Try to use the reset token — should fail (user deleted)
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND)
|
||||
|
||||
|
||||
def test_provision_user(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""
|
||||
Simulates the upstream zeus provisioning flow:
|
||||
1. Invite a user as ADMIN (register already happened via test_register)
|
||||
2. List users to find the invited user's ID
|
||||
3. Get reset password token for that user
|
||||
4. Use the token to set the password and activate the user
|
||||
5. Verify the user can log in
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
provisioned_email = "[email protected]"
|
||||
provisioned_name = "zeus provisioned user"
|
||||
provisioned_password = "password123Z$"
|
||||
|
||||
# Step 1: Invite user as ADMIN (mirrors zeus inviteUserOnO11y)
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={
|
||||
"email": provisioned_email,
|
||||
"name": provisioned_name,
|
||||
"role": "ADMIN",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
# Step 2: List users to find the invited user's ID (mirrors zeus GET /api/v1/user)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/user"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
users = response.json()["data"]
|
||||
found_user = next((u for u in users if u["email"] == provisioned_email), None)
|
||||
assert found_user is not None
|
||||
user_id = found_user["id"]
|
||||
|
||||
# Step 3: Get reset password token (mirrors zeus GET /api/v1/getResetPasswordToken/{id})
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v1/getResetPasswordToken/{user_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
reset_token = response.json()["data"]["token"]
|
||||
assert reset_token is not None
|
||||
assert reset_token != ""
|
||||
|
||||
# Step 4: Use the token to set password and activate user
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": provisioned_password, "token": reset_token},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Step 5: Verify the provisioned user can log in and is active with admin role
|
||||
user_token = get_token(provisioned_email, provisioned_password)
|
||||
assert user_token is not None
|
||||
|
||||
provisioned_user = find_user_with_roles_by_email(o11y, admin_token, provisioned_email)
|
||||
assert provisioned_user["status"] == "active"
|
||||
assert provisioned_user["displayName"] == provisioned_name
|
||||
assert_user_has_role(provisioned_user, "o11y-admin")
|
||||
@@ -1,232 +0,0 @@
|
||||
import http
|
||||
from collections.abc import Callable
|
||||
|
||||
import requests
|
||||
from wiremock.client import (
|
||||
HttpMethods,
|
||||
Mapping,
|
||||
MappingRequest,
|
||||
MappingResponse,
|
||||
WireMockMatchers,
|
||||
)
|
||||
|
||||
from fixtures import types
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
o11y: types.O11y,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
make_http_mocks(
|
||||
o11y.zeus,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.GET,
|
||||
url="/v2/licenses/me",
|
||||
headers={"X-O11y-Cloud-Api-Key": {WireMockMatchers.EQUAL_TO: "secret-key"}},
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"id": "0196360e-90cd-7a74-8313-1aa815ce2a67",
|
||||
"key": "secret-key",
|
||||
"valid_from": 1732146923,
|
||||
"valid_until": -1,
|
||||
"status": "VALID",
|
||||
"state": "EVALUATING",
|
||||
"plan": {
|
||||
"name": "ENTERPRISE",
|
||||
},
|
||||
"platform": "CLOUD",
|
||||
"features": [],
|
||||
"event_queue": {},
|
||||
},
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
access_token = get_token("[email protected]", "password123Z$")
|
||||
|
||||
response = requests.post(
|
||||
url=o11y.self.host_configs["8080"].get("/api/v3/licenses"),
|
||||
json={"key": "secret-key"},
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == http.HTTPStatus.ACCEPTED
|
||||
|
||||
response = requests.post(
|
||||
url=o11y.zeus.host_configs["8080"].get("/__admin/requests/count"),
|
||||
json={"method": "GET", "url": "/v2/licenses/me"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.json()["count"] == 1
|
||||
|
||||
|
||||
def test_refresh_license(
|
||||
o11y: types.O11y,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
make_http_mocks(
|
||||
o11y.zeus,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.GET,
|
||||
url="/v2/licenses/me",
|
||||
headers={"X-O11y-Cloud-Api-Key": {WireMockMatchers.EQUAL_TO: "secret-key"}},
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"id": "0196360e-90cd-7a74-8313-1aa815ce2a67",
|
||||
"key": "secret-key",
|
||||
"valid_from": 1732146922,
|
||||
"valid_until": -1,
|
||||
"status": "VALID",
|
||||
"state": "EVALUATING",
|
||||
"plan": {
|
||||
"name": "ENTERPRISE",
|
||||
},
|
||||
"platform": "CLOUD",
|
||||
"features": [],
|
||||
"event_queue": {},
|
||||
},
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
access_token = get_token("[email protected]", "password123Z$")
|
||||
|
||||
response = requests.put(
|
||||
url=o11y.self.host_configs["8080"].get("/api/v3/licenses"),
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == http.HTTPStatus.NO_CONTENT
|
||||
|
||||
response = requests.get(
|
||||
url=o11y.self.host_configs["8080"].get("/api/v3/licenses/active"),
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == http.HTTPStatus.OK
|
||||
assert response.json()["data"]["valid_from"] == 1732146922
|
||||
|
||||
response = requests.post(
|
||||
url=o11y.zeus.host_configs["8080"].get("/__admin/requests/count"),
|
||||
json={"method": "GET", "url": "/v2/licenses/me"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.json()["count"] == 1
|
||||
|
||||
|
||||
def test_license_checkout(
|
||||
o11y: types.O11y,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
make_http_mocks(
|
||||
o11y.zeus,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.POST,
|
||||
url="/v2/subscriptions/me/sessions/checkout",
|
||||
headers={"X-O11y-Cloud-Api-Key": {WireMockMatchers.EQUAL_TO: "secret-key"}},
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": {"url": "https://o11y.checkout.com"},
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
access_token = get_token("[email protected]", "password123Z$")
|
||||
|
||||
response = requests.post(
|
||||
url=o11y.self.host_configs["8080"].get("/api/v1/checkout"),
|
||||
json={"url": "https://integration-o11y.com"},
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == http.HTTPStatus.CREATED
|
||||
assert response.json()["data"]["redirectURL"] == "https://o11y.checkout.com"
|
||||
|
||||
response = requests.post(
|
||||
url=o11y.zeus.host_configs["8080"].get("/__admin/requests/count"),
|
||||
json={"method": "POST", "url": "/v2/subscriptions/me/sessions/checkout"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.json()["count"] == 1
|
||||
|
||||
|
||||
def test_license_portal(
|
||||
o11y: types.O11y,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
make_http_mocks(
|
||||
o11y.zeus,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.POST,
|
||||
url="/v2/subscriptions/me/sessions/portal",
|
||||
headers={"X-O11y-Cloud-Api-Key": {WireMockMatchers.EQUAL_TO: "secret-key"}},
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": {"url": "https://o11y.portal.com"},
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
access_token = get_token("[email protected]", "password123Z$")
|
||||
|
||||
response = requests.post(
|
||||
url=o11y.self.host_configs["8080"].get("/api/v1/portal"),
|
||||
json={"url": "https://integration-o11y.com"},
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == http.HTTPStatus.CREATED
|
||||
assert response.json()["data"]["redirectURL"] == "https://o11y.portal.com"
|
||||
|
||||
response = requests.post(
|
||||
url=o11y.zeus.host_configs["8080"].get("/__admin/requests/count"),
|
||||
json={"method": "POST", "url": "/v2/subscriptions/me/sessions/portal"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.json()["count"] == 1
|
||||
@@ -1,387 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, find_user_by_email
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
PASSWORD_USER_EMAIL = "[email protected]"
|
||||
PASSWORD_USER_PASSWORD = "password123Z$"
|
||||
|
||||
|
||||
def test_change_password(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create another admin user
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": PASSWORD_USER_EMAIL, "role": "ADMIN"},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
invited_user = response.json()["data"]
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# Reset password to activate user
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": PASSWORD_USER_PASSWORD, "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Try logging in with the password
|
||||
token = get_token(PASSWORD_USER_EMAIL, PASSWORD_USER_PASSWORD)
|
||||
assert token is not None
|
||||
|
||||
# Try changing the password with a bad old password which should fail
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me/factor_password"),
|
||||
json={
|
||||
"oldPassword": "password",
|
||||
"newPassword": PASSWORD_USER_PASSWORD,
|
||||
},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Try changing the password with a good old password
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me/factor_password"),
|
||||
json={
|
||||
"oldPassword": PASSWORD_USER_PASSWORD,
|
||||
"newPassword": "password123Znew$",
|
||||
},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Try logging in with the new password
|
||||
token = get_token(PASSWORD_USER_EMAIL, "password123Znew$")
|
||||
assert token is not None
|
||||
|
||||
|
||||
def test_reset_password(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Get the user id via v2
|
||||
found_user = find_user_by_email(o11y, admin_token, PASSWORD_USER_EMAIL)
|
||||
|
||||
# Create a reset password token via v2 PUT
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
token_data = response.json()["data"]
|
||||
assert "token" in token_data
|
||||
assert "expiresAt" in token_data
|
||||
token = token_data["token"]
|
||||
|
||||
# Calling PUT again should return the same token (still valid)
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
assert response.json()["data"]["token"] == token
|
||||
|
||||
# GET should also return the same token
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["token"] == token
|
||||
|
||||
# Reset the password with a bad password which should fail
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password", "token": token},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Reset the password with a good password
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$NEWNEW#!", "token": token},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
token = get_token(PASSWORD_USER_EMAIL, "password123Z$NEWNEW#!")
|
||||
assert token is not None
|
||||
|
||||
|
||||
def test_reset_password_with_no_password(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Get the user id via v2
|
||||
found_user = find_user_by_email(o11y, admin_token, PASSWORD_USER_EMAIL)
|
||||
|
||||
with o11y.sqlstore.conn.connect() as conn:
|
||||
result = conn.execute(
|
||||
sql.text("DELETE FROM factor_password WHERE user_id = :user_id"),
|
||||
{"user_id": found_user["id"]},
|
||||
)
|
||||
assert result.rowcount == 1
|
||||
|
||||
# GET should return 404 since there's no password (and thus no token)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
|
||||
# Generate a new reset password token via v2 PUT
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
token_data = response.json()["data"]
|
||||
assert "expiresAt" in token_data
|
||||
token = token_data["token"]
|
||||
|
||||
# Reset the password with a good password
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "FINALPASSword123!#[", "token": token},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
token = get_token(PASSWORD_USER_EMAIL, "FINALPASSword123!#[")
|
||||
assert token is not None
|
||||
|
||||
|
||||
def test_forgot_password_returns_204_for_nonexistent_email(
|
||||
o11y: types.O11y,
|
||||
) -> None:
|
||||
"""
|
||||
Test that forgotPassword returns 204 even for non-existent emails
|
||||
(for security reasons - doesn't reveal if user exists).
|
||||
"""
|
||||
# Get org ID first (needed for the forgot password request)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/sessions/context"),
|
||||
params={
|
||||
"email": USER_ADMIN_EMAIL,
|
||||
"ref": f"{o11y.self.host_configs['8080'].base()}",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
org_id = response.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
# Call forgot password with a non-existent email
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/factor_password/forgot"),
|
||||
json={
|
||||
"email": "[email protected]",
|
||||
"orgId": org_id,
|
||||
"frontendBaseURL": o11y.self.host_configs["8080"].base(),
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# Should return 204 even for non-existent email (security)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
def test_forgot_password_creates_reset_token(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""
|
||||
Test the full forgot password flow:
|
||||
1. Call forgotPassword endpoint for existing user
|
||||
2. Verify reset password token is created in database
|
||||
3. Use the token to reset password
|
||||
4. Verify user can login with new password
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
forgot_email = "[email protected]"
|
||||
|
||||
# Create a user specifically for testing forgot password
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={
|
||||
"email": forgot_email,
|
||||
"role": "EDITOR",
|
||||
"name": "forgotpassword user",
|
||||
},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
invited_user = response.json()["data"]
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# Activate user via reset password
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "originalPassword123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Get org ID
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/sessions/context"),
|
||||
params={
|
||||
"email": forgot_email,
|
||||
"ref": f"{o11y.self.host_configs['8080'].base()}",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
org_id = response.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
# Call forgot password endpoint
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/factor_password/forgot"),
|
||||
json={
|
||||
"email": forgot_email,
|
||||
"orgId": org_id,
|
||||
"frontendBaseURL": o11y.self.host_configs["8080"].base(),
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Verify reset password token was created via the v2 GET endpoint
|
||||
found_user = find_user_by_email(o11y, admin_token, forgot_email)
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
token_data = response.json()["data"]
|
||||
reset_token = token_data["token"]
|
||||
assert reset_token is not None
|
||||
assert reset_token != ""
|
||||
assert "expiresAt" in token_data
|
||||
|
||||
# Reset password with a valid strong password
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "newSecurePassword123Z$!", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Verify user can login with the new password
|
||||
user_token = get_token(forgot_email, "newSecurePassword123Z$!")
|
||||
assert user_token is not None
|
||||
|
||||
# Verify old password no longer works
|
||||
try:
|
||||
get_token(forgot_email, "originalPassword123Z$")
|
||||
assert False, "Old password should not work after reset"
|
||||
except AssertionError:
|
||||
pass # Expected - old password should fail
|
||||
|
||||
|
||||
def test_reset_password_with_expired_token(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""
|
||||
Test that resetting password with an expired token fails.
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
forgot_email = "[email protected]"
|
||||
|
||||
# Get user ID via v2
|
||||
found_user = find_user_by_email(o11y, admin_token, forgot_email)
|
||||
|
||||
# Get org ID
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/sessions/context"),
|
||||
params={
|
||||
"email": forgot_email,
|
||||
"ref": f"{o11y.self.host_configs['8080'].base()}",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
org_id = response.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
# Call forgot password to generate a new token
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/factor_password/forgot"),
|
||||
json={
|
||||
"email": forgot_email,
|
||||
"orgId": org_id,
|
||||
"frontendBaseURL": o11y.self.host_configs["8080"].base(),
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Query the database to get the token and then expire it
|
||||
reset_token = None
|
||||
with o11y.sqlstore.conn.connect() as conn:
|
||||
# First get the token
|
||||
result = conn.execute(
|
||||
sql.text(
|
||||
"""
|
||||
SELECT rpt.token, rpt.id
|
||||
FROM reset_password_token rpt
|
||||
JOIN factor_password fp ON rpt.password_id = fp.id
|
||||
WHERE fp.user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": found_user["id"]},
|
||||
)
|
||||
row = result.fetchone()
|
||||
assert row is not None, "Reset password token should exist"
|
||||
reset_token = row[0]
|
||||
token_id = row[1]
|
||||
|
||||
# Now expire the token by setting expires_at to a past time
|
||||
conn.execute(
|
||||
sql.text(
|
||||
"""
|
||||
UPDATE reset_password_token
|
||||
SET expires_at = :expired_time
|
||||
WHERE id = :token_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"expired_time": "2020-01-01 00:00:00",
|
||||
"token_id": token_id,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
assert reset_token is not None
|
||||
|
||||
# Try to use the expired token - should fail with 401 Unauthorized
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "expiredTokenPassword123Z$!", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNAUTHORIZED
|
||||
@@ -1,401 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
USER_EDITOR_EMAIL,
|
||||
USER_EDITOR_PASSWORD,
|
||||
change_user_role,
|
||||
create_active_user,
|
||||
)
|
||||
|
||||
ROLECHANGE_USER_EMAIL = "[email protected]"
|
||||
ROLECHANGE_USER_PASSWORD = "password123Z$"
|
||||
|
||||
|
||||
def test_change_role(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a new user as VIEWER
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": ROLECHANGE_USER_EMAIL, "role": "VIEWER"},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
invited_user = response.json()["data"]
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# Activate user via reset password
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": ROLECHANGE_USER_PASSWORD, "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Make some API calls as new user
|
||||
new_user_token = get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
new_user_data = response.json()["data"]
|
||||
new_user_id = new_user_data["id"]
|
||||
|
||||
# Make some API call which is protected
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/org/preferences"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
# Change the new user's role via v2 - move VIEWER to ADMIN
|
||||
change_user_role(o11y, admin_token, new_user_id, "o11y-viewer", "o11y-admin")
|
||||
|
||||
# Update display name via v2
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{new_user_id}"),
|
||||
json={"displayName": "role change user"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Verify user can now access admin endpoints
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
me_data = response.json()["data"]
|
||||
assert me_data is not None
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/org/preferences"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
def test_get_user_roles(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify GET /api/v2/users/{id}/roles returns correct roles."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# admin+rolechange user was promoted to ADMIN in test_change_role
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
me = response.json()["data"]
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{me['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
|
||||
assert len(roles) >= 1
|
||||
assert "o11y-admin" in {r["name"] for r in roles}
|
||||
# verify role object shape
|
||||
for role in roles:
|
||||
assert "id" in role
|
||||
assert "name" in role
|
||||
assert "type" in role
|
||||
|
||||
|
||||
def test_assign_role_is_additive(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify POST /api/v2/users/{id}/roles ADDS a role alongside existing ones and is idempotent."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
me = response.json()["data"]
|
||||
user_id = me["id"]
|
||||
|
||||
# User currently has o11y-admin from test_change_role.
|
||||
# Assign o11y-editor — should be additive, admin stays.
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
json={"name": "o11y-editor"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
names = {r["name"] for r in roles}
|
||||
assert len(names) == 2
|
||||
assert "o11y-editor" in names
|
||||
assert "o11y-admin" in names
|
||||
|
||||
# Idempotency: assigning the same role again succeeds without duplicates
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
json={"name": "o11y-editor"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
editor_count = sum(1 for r in roles if r["name"] == "o11y-editor")
|
||||
assert editor_count == 1
|
||||
assert len(roles) == 2
|
||||
|
||||
|
||||
def test_get_users_by_role(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify GET /api/v2/roles/{role_id}/users returns users with that role."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
me = response.json()["data"]
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{me['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
editor_role_id = next((r for r in roles if r["name"] == "o11y-editor"), None)["id"]
|
||||
assert editor_role_id is not None
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/roles/{editor_role_id}/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
user_emails = {u["email"] for u in response.json()["data"]}
|
||||
assert ROLECHANGE_USER_EMAIL in user_emails
|
||||
|
||||
|
||||
def test_remove_role(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify DELETE /api/v2/users/{id}/roles/{roleId} removes only the specified role."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
me = response.json()["data"]
|
||||
user_id = me["id"]
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
editor_role_id = next((r for r in roles if r["name"] == "o11y-editor"), None)["id"]
|
||||
assert editor_role_id is not None
|
||||
|
||||
response = requests.delete(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles/{editor_role_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles_after = response.json()["data"]
|
||||
names_after = {r["name"] for r in roles_after}
|
||||
assert len(roles_after) == 1
|
||||
assert "o11y-editor" not in names_after
|
||||
assert "o11y-admin" in names_after
|
||||
|
||||
|
||||
def test_user_with_roles_reflects_change(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify GET /api/v2/users/{id} userRoles reflects role removal."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
me = response.json()["data"]
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{me['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()["data"]
|
||||
role_names = {ur["role"]["name"] for ur in data["userRoles"]}
|
||||
assert len(role_names) == 1
|
||||
assert "o11y-admin" in role_names
|
||||
|
||||
|
||||
def test_admin_cannot_assign_role_to_self(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify POST /api/v2/users/{own_id}/roles is rejected (self-mutation guard)."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
admin_data = response.json()["data"]
|
||||
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles"),
|
||||
json={"name": "o11y-editor"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_admin_cannot_remove_own_role(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify DELETE /api/v2/users/{own_id}/roles/{roleId} is rejected (self-mutation guard)."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
admin_data = response.json()["data"]
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
admin_role_id = next((r for r in roles if r["name"] == "o11y-admin"), None)["id"]
|
||||
assert admin_role_id is not None
|
||||
|
||||
response = requests.delete(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles/{admin_role_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_editor_cannot_manage_roles(
|
||||
o11y: types.O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify non-admin cannot call role management endpoints."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# create a viewer user to be the target
|
||||
viewer_id = create_active_user(
|
||||
o11y,
|
||||
admin_token,
|
||||
email="[email protected]",
|
||||
role="VIEWER",
|
||||
password=ROLECHANGE_USER_PASSWORD,
|
||||
name="viewer roleauth",
|
||||
)
|
||||
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
|
||||
# GET roles — forbidden
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
# POST assign role — forbidden
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles"),
|
||||
json={"name": "o11y-editor"},
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
# DELETE remove role — forbidden
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
viewer_roles = response.json()["data"]
|
||||
viewer_role_id = next((r for r in viewer_roles if r["name"] == "o11y-viewer"), None)["id"]
|
||||
|
||||
response = requests.delete(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
@@ -1,58 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.types import O11y
|
||||
|
||||
DUPLICATE_USER_EMAIL = "[email protected]"
|
||||
|
||||
|
||||
def test_duplicate_user_invite_rejected(
|
||||
o11y: O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""
|
||||
Verify that the unique index on (email, org_id) in the users table prevents
|
||||
creating duplicate users. This invites a new user, accepts the invite, then
|
||||
tries to invite and accept the same email again expecting a failure.
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Invite a new user
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": DUPLICATE_USER_EMAIL, "role": "EDITOR"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
invited_user = response.json()["data"]
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# Invite the same email again — should fail
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
|
||||
# activate the user
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Try to invite the same email again — should fail
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
@@ -1,184 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, create_active_user
|
||||
from fixtures.types import O11y
|
||||
|
||||
|
||||
def test_reinvite_deleted_user(
|
||||
o11y: O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""
|
||||
Verify that a deleted user if re-inivited creates a new user altogether:
|
||||
1. Invite and activate a user
|
||||
2. Call the delete user api
|
||||
3. Re-invite the same email — should succeed and create a new user with pending_invite status
|
||||
4. Reset password for the new user
|
||||
5. Get User API returns two users now, one deleted and one active
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
reinvite_user_email = "[email protected]"
|
||||
reinvite_user_name = "reinvite user"
|
||||
reinvite_user_role = "EDITOR"
|
||||
reinvite_user_password = "password123Z$"
|
||||
|
||||
# invite the user
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={
|
||||
"email": reinvite_user_email,
|
||||
"role": reinvite_user_role,
|
||||
"name": reinvite_user_name,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
invited_user = response.json()["data"]
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# reset the password to make it active
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": reinvite_user_password, "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# call the delete api which now soft deletes the user
|
||||
response = requests.delete(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v1/user/{invited_user['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Re-invite the same email — should succeed
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={
|
||||
"email": reinvite_user_email,
|
||||
"role": "VIEWER",
|
||||
"name": "reinvite user v2",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
reinvited_user = response.json()["data"]
|
||||
assert reinvited_user["role"] == "VIEWER"
|
||||
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
|
||||
|
||||
reinvited_user_reset_password_token = reinvited_user["token"]
|
||||
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={
|
||||
"password": "newPassword123Z$",
|
||||
"token": reinvited_user_reset_password_token,
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Verify user can log in with new password
|
||||
user_token = get_token("[email protected]", "newPassword123Z$")
|
||||
assert user_token is not None
|
||||
|
||||
|
||||
def test_bulk_invite(
|
||||
o11y: O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""
|
||||
Verify the bulk invite endpoint creates multiple pending_invite users.
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite/bulk"),
|
||||
json={
|
||||
"invites": [
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"role": "EDITOR",
|
||||
"name": "bulk user 1",
|
||||
},
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"role": "VIEWER",
|
||||
"name": "bulk user 2",
|
||||
},
|
||||
]
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
|
||||
def test_delete_user(
|
||||
o11y: O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""
|
||||
Verify that after soft-deleting a user:
|
||||
1. GET /api/v2/users shows the user with status == "deleted"
|
||||
2. GET /api/v2/users/{id} returns the user with empty userRoles (roles revoked)
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
user_id = create_active_user(
|
||||
o11y,
|
||||
admin_token,
|
||||
email="[email protected]",
|
||||
role="EDITOR",
|
||||
password="password123Z$",
|
||||
name="delete verify v2",
|
||||
)
|
||||
|
||||
# verify user is active via v2
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()["data"]
|
||||
assert data["status"] == "active"
|
||||
assert len(data["userRoles"]) == 1
|
||||
|
||||
# delete the user
|
||||
response = requests.delete(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v1/user/{user_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# verify status is deleted in the users list
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
users = response.json()["data"]
|
||||
deleted_user = next((u for u in users if u["id"] == user_id), None)
|
||||
assert deleted_user is not None
|
||||
assert deleted_user["status"] == "deleted"
|
||||
|
||||
# verify roles are revoked
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()["data"]
|
||||
assert data["status"] == "deleted"
|
||||
assert len(data["userRoles"]) == 1
|
||||
@@ -1,157 +0,0 @@
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.types import O11y
|
||||
|
||||
UNIQUE_INDEX_USER_EMAIL = "[email protected]"
|
||||
|
||||
|
||||
def test_unique_index_allows_multiple_deleted_rows(
|
||||
o11y: O11y,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""
|
||||
Verify that the partial unique index on (email, org_id) WHERE status != 'deleted'
|
||||
allows multiple deleted rows for the same (org_id, email) while still enforcing
|
||||
uniqueness among non-deleted rows.
|
||||
|
||||
The partial unique index only covers rows where status != 'deleted', so active
|
||||
users cannot share the same (org_id, email). Deleted users are excluded from
|
||||
the index, allowing multiple deleted rows for the same (org_id, email).
|
||||
|
||||
Steps:
|
||||
1. Invite and soft-delete a user via the API (first deleted row).
|
||||
2. Re-invite and soft-delete the same email via the API (second deleted row).
|
||||
3. Assert via SQL that exactly two deleted rows exist for the email.
|
||||
4. Assert via SQL that inserting one active row succeeds (no conflict — only
|
||||
deleted rows exist), then inserting a second active row for the same
|
||||
(org_id, email) fails with a unique constraint error.
|
||||
5. Assert via SQL that inserting a third deleted row for the same (org_id, email)
|
||||
succeeds — confirming the index does not cover deleted rows.
|
||||
6. Assert via SQL that the final count of deleted rows is 3.
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Step 1: invite and delete the first user
|
||||
resp = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={
|
||||
"email": UNIQUE_INDEX_USER_EMAIL,
|
||||
"role": "EDITOR",
|
||||
"name": "unique index user v1",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
first_user_id = resp.json()["data"]["id"]
|
||||
|
||||
resp = requests.delete(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v1/user/{first_user_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Step 2: re-invite and delete the same email (second deleted row)
|
||||
resp = requests.post(
|
||||
o11y.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={
|
||||
"email": UNIQUE_INDEX_USER_EMAIL,
|
||||
"role": "EDITOR",
|
||||
"name": "unique index user v2",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
second_user_id = resp.json()["data"]["id"]
|
||||
assert second_user_id != first_user_id
|
||||
|
||||
resp = requests.delete(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v1/user/{second_user_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Step 3: assert the DB has exactly two deleted rows for this email
|
||||
with o11y.sqlstore.conn.connect() as conn:
|
||||
result = conn.execute(
|
||||
sql.text("SELECT id FROM users WHERE email = :email AND status = 'deleted'"),
|
||||
{"email": UNIQUE_INDEX_USER_EMAIL},
|
||||
)
|
||||
deleted_rows = result.fetchall()
|
||||
|
||||
assert len(deleted_rows) == 2, f"expected 2 deleted rows for {UNIQUE_INDEX_USER_EMAIL}, got {len(deleted_rows)}"
|
||||
deleted_ids = {row[0] for row in deleted_rows}
|
||||
assert first_user_id in deleted_ids
|
||||
assert second_user_id in deleted_ids
|
||||
|
||||
# Retrieve org_id for the direct SQL inserts below
|
||||
with o11y.sqlstore.conn.connect() as conn:
|
||||
result = conn.execute(
|
||||
sql.text("SELECT org_id FROM users WHERE id = :id"),
|
||||
{"id": first_user_id},
|
||||
)
|
||||
org_id = result.fetchone()[0]
|
||||
|
||||
# Step 4: the unique index must still block a duplicate non-deleted row.
|
||||
# The partial unique index covers rows WHERE status != 'deleted', so two active
|
||||
# rows with the same (org_id, email) must conflict.
|
||||
# First insert must succeed (only deleted rows exist so far).
|
||||
# Second insert for the same (org_id, email) with status='active' must fail.
|
||||
active_id = str(uuid.uuid4())
|
||||
with o11y.sqlstore.conn.connect() as conn:
|
||||
conn.execute(
|
||||
sql.text("INSERT INTO users (id, display_name, email, org_id, is_root, status, created_at, updated_at) VALUES (:id, :display_name, :email, :org_id, false, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"),
|
||||
{
|
||||
"id": active_id,
|
||||
"display_name": "first active row",
|
||||
"email": UNIQUE_INDEX_USER_EMAIL,
|
||||
"org_id": org_id,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
with o11y.sqlstore.conn.connect() as conn:
|
||||
with pytest.raises(IntegrityError):
|
||||
conn.execute(
|
||||
sql.text("INSERT INTO users (id, display_name, email, org_id, is_root, status, created_at, updated_at) VALUES (:id, :display_name, :email, :org_id, false, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"display_name": "should violate index",
|
||||
"email": UNIQUE_INDEX_USER_EMAIL,
|
||||
"org_id": org_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Step 5: a third deleted row must be accepted (excluded from partial index)
|
||||
with o11y.sqlstore.conn.connect() as conn:
|
||||
conn.execute(
|
||||
sql.text("INSERT INTO users (id, display_name, email, org_id, is_root, status, created_at, updated_at) VALUES (:id, :display_name, :email, :org_id, false, 'deleted', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"display_name": "third deleted row",
|
||||
"email": UNIQUE_INDEX_USER_EMAIL,
|
||||
"org_id": org_id,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Step 6: confirm three deleted rows now exist
|
||||
with o11y.sqlstore.conn.connect() as conn:
|
||||
result = conn.execute(
|
||||
sql.text("SELECT COUNT(*) FROM users WHERE email = :email AND status = 'deleted'"),
|
||||
{"email": UNIQUE_INDEX_USER_EMAIL},
|
||||
)
|
||||
count = result.fetchone()[0]
|
||||
|
||||
assert count == 3, f"expected 3 deleted rows after direct insert, got {count}"
|
||||
@@ -1,187 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
USER_EDITOR_EMAIL,
|
||||
USER_EDITOR_PASSWORD,
|
||||
assert_user_has_role,
|
||||
find_user_by_email,
|
||||
find_user_with_roles_by_email,
|
||||
)
|
||||
|
||||
|
||||
def test_list_users(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify GET /api/v2/users returns all users with correct fields."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
users = response.json()["data"]
|
||||
|
||||
admin_user = next((u for u in users if u["email"] == USER_ADMIN_EMAIL), None)
|
||||
assert admin_user is not None
|
||||
assert admin_user["isRoot"] is True
|
||||
assert admin_user["status"] == "active"
|
||||
|
||||
editor_user = next((u for u in users if u["email"] == USER_EDITOR_EMAIL), None)
|
||||
assert editor_user is not None
|
||||
assert editor_user["status"] == "active"
|
||||
|
||||
|
||||
def test_get_user(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify GET /api/v2/users/{id} returns user with roles."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
editor_user = find_user_by_email(o11y, admin_token, USER_EDITOR_EMAIL)
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{editor_user['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()["data"]
|
||||
assert data["email"] == USER_EDITOR_EMAIL
|
||||
assert data["status"] == "active"
|
||||
assert len(data["userRoles"]) >= 1
|
||||
assert_user_has_role(data, "o11y-editor")
|
||||
|
||||
|
||||
def test_get_my_user(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify GET /api/v2/users/me returns authenticated user with roles."""
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()["data"]
|
||||
assert data["email"] == USER_EDITOR_EMAIL
|
||||
assert data["status"] == "active"
|
||||
assert data["isRoot"] is False
|
||||
assert_user_has_role(data, "o11y-editor")
|
||||
|
||||
|
||||
def test_update_user(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify PUT /api/v2/users/{id} updates displayName."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
editor_user = find_user_by_email(o11y, admin_token, USER_EDITOR_EMAIL)
|
||||
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{editor_user['id']}"),
|
||||
json={"displayName": "updated editor"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
updated = find_user_with_roles_by_email(o11y, admin_token, USER_EDITOR_EMAIL)
|
||||
assert updated["displayName"] == "updated editor"
|
||||
|
||||
|
||||
def test_update_my_user(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify PUT /api/v2/users/me updates own displayName."""
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
json={"displayName": "self updated editor"},
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["displayName"] == "self updated editor"
|
||||
|
||||
|
||||
def test_admin_cannot_update_self_via_id(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify PUT /api/v2/users/{own_id} is rejected (self-mutation guard)."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
admin_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{admin_id}"),
|
||||
json={"displayName": "should fail"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_editor_cannot_list_users(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify non-admin cannot call GET /api/v2/users."""
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
def test_editor_cannot_get_other_user(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify non-admin cannot call GET /api/v2/users/{other_id}."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
admin_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{admin_id}"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
def test_editor_cannot_update_other_user(o11y: types.O11y, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify non-admin cannot call PUT /api/v2/users/{other_id}."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
o11y.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
admin_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.put(
|
||||
o11y.self.host_configs["8080"].get(f"/api/v2/users/{admin_id}"),
|
||||
json={"displayName": "hacked"},
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
-536
@@ -37,9 +37,6 @@ func init() {
|
||||
"O11yDashboardIDIn.id": "ID is the resource id from the path.",
|
||||
},
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/domains/:id", zip.Doc{
|
||||
Description: "Releases an email domain and discards its SSO\nconfiguration, by id. Admin gate.",
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/downtime_schedules/:id", zip.Doc{
|
||||
Description: "Removes a planned maintenance window, by id. Editor gate.",
|
||||
})
|
||||
@@ -86,9 +83,6 @@ func init() {
|
||||
zip.Describe("DELETE /v1/o11y/service_accounts/:id/roles/:rid", zip.Doc{
|
||||
Description: "Removes a role from a service account.",
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/sessions", zip.Doc{
|
||||
Description: "Signs the calling session out, invalidating its tokens. The\naccess token on the call names the session to end.",
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/span_mapper_groups/:groupId", zip.Doc{
|
||||
Description: "Deletes a mapping group and every mapper under it.\n\nCallers need the admin role; the runtime's own gate enforces it.",
|
||||
})
|
||||
@@ -101,15 +95,6 @@ func init() {
|
||||
"O11yFunnelDeleteOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/user/:id", zip.Doc{
|
||||
Description: "Removes one org member, by user id. The same operation\nas deleteUser on the legacy singular path. Admin gate.",
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/users/:id", zip.Doc{
|
||||
Description: "Removes one org member, by user id. Admin gate.",
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/users/:id/roles/:roleId", zip.Doc{
|
||||
Description: "Takes a role away from one org member, by user id and role\nid — someone else, never the caller. Admin gate.",
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/users/me/dashboards/:id/pins", zip.Doc{
|
||||
Description: "Removes the caller's pin for a dashboard. Idempotent —\nunpinning a dashboard that was not pinned still succeeds.\n\nCallers need the viewer role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
@@ -420,104 +405,6 @@ func init() {
|
||||
"O11yDisk.type": "Type is the disk's type, e.g. local or s3.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/domains", zip.Doc{
|
||||
Description: "Lists the org's auth domains — the email domains whose SSO\nconfiguration this org owns. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yAttributeMapping.email": "Email is the key carrying the email; defaults to \"email\".",
|
||||
"O11yAttributeMapping.groups": "Groups is the key carrying the group list; defaults to \"groups\".",
|
||||
"O11yAttributeMapping.name": "Name is the key carrying the display name; defaults to \"name\".",
|
||||
"O11yAttributeMapping.role": "Role is the key carrying the role; defaults to \"role\".",
|
||||
"O11yAuthDomain.authNProviderInfo": "AuthNProviderInfo is provider detail the console needs to finish setup.",
|
||||
"O11yAuthDomain.config": "Config is the domain's SSO configuration.",
|
||||
"O11yAuthDomain.createdAt": "CreatedAt is when it was claimed.",
|
||||
"O11yAuthDomain.id": "ID is the auth domain id.",
|
||||
"O11yAuthDomain.name": "Name is the email domain, e.g. example.com.",
|
||||
"O11yAuthDomain.orgId": "OrgID is the org that claimed it.",
|
||||
"O11yAuthDomain.updatedAt": "UpdatedAt is when its configuration last changed.",
|
||||
"O11yAuthDomainConfig.googleAuthConfig": "Google is the Google provider's settings, when SSOType is google_auth.",
|
||||
"O11yAuthDomainConfig.oidcConfig": "OIDC is the OIDC provider's settings, when SSOType is oidc.",
|
||||
"O11yAuthDomainConfig.roleMapping": "RoleMapping maps the provider's groups onto roles for new users.",
|
||||
"O11yAuthDomainConfig.samlConfig": "SAML is the SAML provider's settings, when SSOType is saml.",
|
||||
"O11yAuthDomainConfig.ssoEnabled": "SSOEnabled turns enforced SSO on for the domain.",
|
||||
"O11yAuthDomainConfig.ssoType": "SSOType picks the provider — saml, google_auth or oidc.",
|
||||
"O11yAuthDomainsOut.data": "Data holds the domains.",
|
||||
"O11yAuthDomainsOut.status": "Status is \"success\".",
|
||||
"O11yAuthNProviderInfo.relayStatePath": "RelayStatePath is the relay-state path a SAML IdP must be configured\nwith, when the provider needs one.",
|
||||
"O11yGoogleConfig.allowedGroups": "AllowedGroups, when set, admits only members of these groups.",
|
||||
"O11yGoogleConfig.clientId": "ClientID is the OAuth application's id.",
|
||||
"O11yGoogleConfig.clientSecret": "ClientSecret is the OAuth application's secret.",
|
||||
"O11yGoogleConfig.domainToAdminEmail": "DomainToAdminEmail maps each Workspace domain to the admin the service\naccount impersonates; \"*\" is the fallback.",
|
||||
"O11yGoogleConfig.fetchGroups": "FetchGroups reads the user's Workspace groups for role mapping.",
|
||||
"O11yGoogleConfig.fetchTransitiveGroupMembership": "FetchTransitiveGroupMembership also reads groups held through other\ngroups.",
|
||||
"O11yGoogleConfig.insecureSkipEmailVerified": "InsecureSkipEmailVerified admits addresses Google has not verified.",
|
||||
"O11yGoogleConfig.redirectURI": "RedirectURI is the callback the flow returns to.",
|
||||
"O11yGoogleConfig.serviceAccountJson": "ServiceAccountJSON is the service-account credential used to read\ngroups, when FetchGroups is on.",
|
||||
"O11yOIDCConfig.claimMapping": "ClaimMapping names the token claims to read identity from.",
|
||||
"O11yOIDCConfig.clientId": "ClientID is the OAuth application's id.",
|
||||
"O11yOIDCConfig.clientSecret": "ClientSecret is the OAuth application's secret.",
|
||||
"O11yOIDCConfig.getUserInfo": "GetUserInfo also queries the userinfo endpoint, for providers whose id\ntokens are thin.",
|
||||
"O11yOIDCConfig.insecureSkipEmailVerified": "InsecureSkipEmailVerified admits addresses the provider has not\nverified.",
|
||||
"O11yOIDCConfig.issuer": "Issuer is the provider's issuer URL.",
|
||||
"O11yOIDCConfig.issuerAlias": "IssuerAlias overrides the issuer for providers whose discovery document\ndisagrees with their issuer URL.",
|
||||
"O11yRoleMapping.defaultRole": "DefaultRole is the role when no group mapping applies.",
|
||||
"O11yRoleMapping.groupMappings": "GroupMappings maps a provider group name to a role name.",
|
||||
"O11yRoleMapping.useRoleAttribute": "UseRoleAttribute reads the role straight from the provider's role claim\ninstead of the group mappings.",
|
||||
"O11ySAMLConfig.attributeMapping": "AttributeMapping names the assertion attributes to read identity from.",
|
||||
"O11ySAMLConfig.insecureSkipAuthNRequestsSigned": "InsecureSkipAuthNRequestsSigned skips signing outgoing AuthN requests,\nfor IdPs that refuse signed ones.",
|
||||
"O11ySAMLConfig.samlCert": "SamlCert is the IdP's signing certificate.",
|
||||
"O11ySAMLConfig.samlEntity": "SamlEntity is the IdP's entityID.",
|
||||
"O11ySAMLConfig.samlIdp": "SamlIdp is the IdP's single-sign-on endpoint.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/domains/:id", zip.Doc{
|
||||
Description: "Returns one auth domain with its SSO configuration, by id.\nAdmin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yAttributeMapping.email": "Email is the key carrying the email; defaults to \"email\".",
|
||||
"O11yAttributeMapping.groups": "Groups is the key carrying the group list; defaults to \"groups\".",
|
||||
"O11yAttributeMapping.name": "Name is the key carrying the display name; defaults to \"name\".",
|
||||
"O11yAttributeMapping.role": "Role is the key carrying the role; defaults to \"role\".",
|
||||
"O11yAuthDomain.authNProviderInfo": "AuthNProviderInfo is provider detail the console needs to finish setup.",
|
||||
"O11yAuthDomain.config": "Config is the domain's SSO configuration.",
|
||||
"O11yAuthDomain.createdAt": "CreatedAt is when it was claimed.",
|
||||
"O11yAuthDomain.id": "ID is the auth domain id.",
|
||||
"O11yAuthDomain.name": "Name is the email domain, e.g. example.com.",
|
||||
"O11yAuthDomain.orgId": "OrgID is the org that claimed it.",
|
||||
"O11yAuthDomain.updatedAt": "UpdatedAt is when its configuration last changed.",
|
||||
"O11yAuthDomainConfig.googleAuthConfig": "Google is the Google provider's settings, when SSOType is google_auth.",
|
||||
"O11yAuthDomainConfig.oidcConfig": "OIDC is the OIDC provider's settings, when SSOType is oidc.",
|
||||
"O11yAuthDomainConfig.roleMapping": "RoleMapping maps the provider's groups onto roles for new users.",
|
||||
"O11yAuthDomainConfig.samlConfig": "SAML is the SAML provider's settings, when SSOType is saml.",
|
||||
"O11yAuthDomainConfig.ssoEnabled": "SSOEnabled turns enforced SSO on for the domain.",
|
||||
"O11yAuthDomainConfig.ssoType": "SSOType picks the provider — saml, google_auth or oidc.",
|
||||
"O11yAuthDomainOut.data": "Data is the domain.",
|
||||
"O11yAuthDomainOut.status": "Status is \"success\".",
|
||||
"O11yAuthNProviderInfo.relayStatePath": "RelayStatePath is the relay-state path a SAML IdP must be configured\nwith, when the provider needs one.",
|
||||
"O11yGoogleConfig.allowedGroups": "AllowedGroups, when set, admits only members of these groups.",
|
||||
"O11yGoogleConfig.clientId": "ClientID is the OAuth application's id.",
|
||||
"O11yGoogleConfig.clientSecret": "ClientSecret is the OAuth application's secret.",
|
||||
"O11yGoogleConfig.domainToAdminEmail": "DomainToAdminEmail maps each Workspace domain to the admin the service\naccount impersonates; \"*\" is the fallback.",
|
||||
"O11yGoogleConfig.fetchGroups": "FetchGroups reads the user's Workspace groups for role mapping.",
|
||||
"O11yGoogleConfig.fetchTransitiveGroupMembership": "FetchTransitiveGroupMembership also reads groups held through other\ngroups.",
|
||||
"O11yGoogleConfig.insecureSkipEmailVerified": "InsecureSkipEmailVerified admits addresses Google has not verified.",
|
||||
"O11yGoogleConfig.redirectURI": "RedirectURI is the callback the flow returns to.",
|
||||
"O11yGoogleConfig.serviceAccountJson": "ServiceAccountJSON is the service-account credential used to read\ngroups, when FetchGroups is on.",
|
||||
"O11yOIDCConfig.claimMapping": "ClaimMapping names the token claims to read identity from.",
|
||||
"O11yOIDCConfig.clientId": "ClientID is the OAuth application's id.",
|
||||
"O11yOIDCConfig.clientSecret": "ClientSecret is the OAuth application's secret.",
|
||||
"O11yOIDCConfig.getUserInfo": "GetUserInfo also queries the userinfo endpoint, for providers whose id\ntokens are thin.",
|
||||
"O11yOIDCConfig.insecureSkipEmailVerified": "InsecureSkipEmailVerified admits addresses the provider has not\nverified.",
|
||||
"O11yOIDCConfig.issuer": "Issuer is the provider's issuer URL.",
|
||||
"O11yOIDCConfig.issuerAlias": "IssuerAlias overrides the issuer for providers whose discovery document\ndisagrees with their issuer URL.",
|
||||
"O11yRoleMapping.defaultRole": "DefaultRole is the role when no group mapping applies.",
|
||||
"O11yRoleMapping.groupMappings": "GroupMappings maps a provider group name to a role name.",
|
||||
"O11yRoleMapping.useRoleAttribute": "UseRoleAttribute reads the role straight from the provider's role claim\ninstead of the group mappings.",
|
||||
"O11ySAMLConfig.attributeMapping": "AttributeMapping names the assertion attributes to read identity from.",
|
||||
"O11ySAMLConfig.insecureSkipAuthNRequestsSigned": "InsecureSkipAuthNRequestsSigned skips signing outgoing AuthN requests,\nfor IdPs that refuse signed ones.",
|
||||
"O11ySAMLConfig.samlCert": "SamlCert is the IdP's signing certificate.",
|
||||
"O11ySAMLConfig.samlEntity": "SamlEntity is the IdP's entityID.",
|
||||
"O11ySAMLConfig.samlIdp": "SamlIdp is the IdP's single-sign-on endpoint.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/downtime_schedules", zip.Doc{
|
||||
Description: "Lists all planned maintenance windows, optionally\nnarrowed to the active ones or the recurring ones. Viewer gate.",
|
||||
Fields: map[string]string{
|
||||
@@ -790,17 +677,6 @@ func init() {
|
||||
"O11ySearchIngestionKeysIn.per_page": "PerPage is the page size.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/getResetPasswordToken/:id", zip.Doc{
|
||||
Description: "Returns a user's password-reset token, creating one\nif none is live. Deprecated in favor of the reset_password_tokens pair,\nwhich separates reading from minting. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yResetToken.expiresAt": "ExpiresAt is when it stops working.",
|
||||
"O11yResetToken.id": "ID is the grant's id.",
|
||||
"O11yResetToken.passwordId": "PasswordID is the password record it resets.",
|
||||
"O11yResetToken.token": "Token is the secret that redeems it.",
|
||||
"O11yResetTokenOut.data": "Data is the token.",
|
||||
"O11yResetTokenOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/global/config", zip.Doc{
|
||||
Description: "Returns the deployment's global configuration: its public\nendpoints and which identity providers are enabled. Open by design — the\nsign-in page reads it before anyone is signed in.\n\nOpen by design; the runtime's own gate is OpenAccess.",
|
||||
Fields: map[string]string{
|
||||
@@ -1895,21 +1771,6 @@ func init() {
|
||||
"O11yTransactionGroup.relation": "Relation is the verb the grant allows.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/roles/:id/users", zip.Doc{
|
||||
Description: "Returns every org member holding a role, by role id. Admin\ngate.",
|
||||
Fields: map[string]string{
|
||||
"O11yUser.createdAt": "CreatedAt is when they joined.",
|
||||
"O11yUser.displayName": "DisplayName is what the console shows for them.",
|
||||
"O11yUser.email": "Email is their address.",
|
||||
"O11yUser.id": "ID is the user id.",
|
||||
"O11yUser.isRoot": "IsRoot marks the org's root user, which cannot be deleted or demoted.",
|
||||
"O11yUser.orgId": "OrgID is the org they belong to.",
|
||||
"O11yUser.status": "Status is their lifecycle state — active, pending_invite or deleted.",
|
||||
"O11yUser.updatedAt": "UpdatedAt is when their record last changed.",
|
||||
"O11yUsersOut.data": "Data holds the members.",
|
||||
"O11yUsersOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/route_policies", zip.Doc{
|
||||
Description: "Lists the org's route policies. Viewer gate.",
|
||||
Fields: map[string]string{
|
||||
@@ -2120,36 +1981,6 @@ func init() {
|
||||
zip.Describe("GET /v1/o11y/services/list", zip.Doc{
|
||||
Description: "Lists the name of every service the trace store holds, with no\nwindow applied — the complete catalog, for pickers and autocomplete.",
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/sessions/context", zip.Doc{
|
||||
Description: "Tells a sign-in page what an email address can do: which\norgs the address belongs to and, per org, which password and SSO routes are\nopen to it. Unauthenticated: it runs before any session exists.",
|
||||
Fields: map[string]string{
|
||||
"O11yAuthNSupport.callback": "Callback are the SSO routes; each is begun by visiting its URL.",
|
||||
"O11yAuthNSupport.password": "Password are the password routes.",
|
||||
"O11yCallbackAuthN.provider": "Provider is the route's provider — google_auth, saml or oidc.",
|
||||
"O11yCallbackAuthN.url": "URL is where the browser goes to begin the flow.",
|
||||
"O11yErrorDetail.code": "Code is the machine-readable code.",
|
||||
"O11yErrorDetail.errors": "Errors are further details, one message and its suggestions each.",
|
||||
"O11yErrorDetail.message": "Message is the human-readable reason.",
|
||||
"O11yErrorDetail.retry": "Retry says when it is worth trying again, for errors that pass.",
|
||||
"O11yErrorDetail.suggestions": "Suggestions say what to try instead.",
|
||||
"O11yErrorDetail.type": "Type is the error's category, e.g. invalid_input, not_found.",
|
||||
"O11yErrorDetail.url": "Url points at documentation for the error, when there is any.",
|
||||
"O11yErrorItem.message": "Message is the detail.",
|
||||
"O11yErrorItem.suggestions": "Suggestions say what to try about this detail.",
|
||||
"O11yPasswordAuthN.provider": "Provider is the route's provider, e.g. email_password.",
|
||||
"O11yRetry.delay": "Delay is how long to wait before retrying, in nanoseconds.",
|
||||
"O11ySessionContext.exists": "Exists says whether any account carries the address.",
|
||||
"O11ySessionContext.orgs": "Orgs are the orgs the address belongs to, each with its sign-in routes.",
|
||||
"O11ySessionContextIn.email": "Email is the address about to sign in. Required.",
|
||||
"O11ySessionContextIn.ref": "Ref is the page the sign-in started from, carried into SSO redirects.",
|
||||
"O11ySessionContextOut.data": "Data is the context.",
|
||||
"O11ySessionContextOut.status": "Status is \"success\".",
|
||||
"O11ySessionOrg.authNSupport": "AuthNSupport lists the org's open sign-in routes.",
|
||||
"O11ySessionOrg.id": "ID is the org id.",
|
||||
"O11ySessionOrg.name": "Name is the org's display name.",
|
||||
"O11ySessionOrg.warning": "Warning reports an org whose SSO is configured but not currently usable,\nin the platform's error shape.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/settings/apdex", zip.Doc{
|
||||
Description: "Returns apdex settings for the named services.\n\nCallers need the viewer role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
@@ -2279,54 +2110,6 @@ func init() {
|
||||
"O11yUsageItem.timestamp": "Timestamp is the bucket start, as epoch nanoseconds.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/user", zip.Doc{
|
||||
Description: "Lists the org's members with their single legacy role.\nDeprecated in favor of listUsers, which answers without the role. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yDeprecatedUser.createdAt": "CreatedAt is when they joined.",
|
||||
"O11yDeprecatedUser.displayName": "DisplayName is what the console shows for them.",
|
||||
"O11yDeprecatedUser.email": "Email is their address.",
|
||||
"O11yDeprecatedUser.id": "ID is the user id.",
|
||||
"O11yDeprecatedUser.isRoot": "IsRoot marks the org's root user.",
|
||||
"O11yDeprecatedUser.orgId": "OrgID is the org they belong to.",
|
||||
"O11yDeprecatedUser.role": "Role is their legacy role — ADMIN, EDITOR or VIEWER.",
|
||||
"O11yDeprecatedUser.status": "Status is their lifecycle state — active, pending_invite or deleted.",
|
||||
"O11yDeprecatedUser.updatedAt": "UpdatedAt is when their record last changed.",
|
||||
"O11yDeprecatedUsersOut.data": "Data holds the members.",
|
||||
"O11yDeprecatedUsersOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/user/:id", zip.Doc{
|
||||
Description: "Returns one org member with their single legacy role, by\nuser id. Admins may read anyone; a non-admin only themselves (the runtime's\nself-access gate).",
|
||||
Fields: map[string]string{
|
||||
"O11yDeprecatedUser.createdAt": "CreatedAt is when they joined.",
|
||||
"O11yDeprecatedUser.displayName": "DisplayName is what the console shows for them.",
|
||||
"O11yDeprecatedUser.email": "Email is their address.",
|
||||
"O11yDeprecatedUser.id": "ID is the user id.",
|
||||
"O11yDeprecatedUser.isRoot": "IsRoot marks the org's root user.",
|
||||
"O11yDeprecatedUser.orgId": "OrgID is the org they belong to.",
|
||||
"O11yDeprecatedUser.role": "Role is their legacy role — ADMIN, EDITOR or VIEWER.",
|
||||
"O11yDeprecatedUser.status": "Status is their lifecycle state — active, pending_invite or deleted.",
|
||||
"O11yDeprecatedUser.updatedAt": "UpdatedAt is when their record last changed.",
|
||||
"O11yDeprecatedUserOut.data": "Data is the member.",
|
||||
"O11yDeprecatedUserOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/user/me", zip.Doc{
|
||||
Description: "Returns the calling user with their single legacy role.\nDeprecated in favor of getMyUser. Open to any authenticated caller.",
|
||||
Fields: map[string]string{
|
||||
"O11yDeprecatedUser.createdAt": "CreatedAt is when they joined.",
|
||||
"O11yDeprecatedUser.displayName": "DisplayName is what the console shows for them.",
|
||||
"O11yDeprecatedUser.email": "Email is their address.",
|
||||
"O11yDeprecatedUser.id": "ID is the user id.",
|
||||
"O11yDeprecatedUser.isRoot": "IsRoot marks the org's root user.",
|
||||
"O11yDeprecatedUser.orgId": "OrgID is the org they belong to.",
|
||||
"O11yDeprecatedUser.role": "Role is their legacy role — ADMIN, EDITOR or VIEWER.",
|
||||
"O11yDeprecatedUser.status": "Status is their lifecycle state — active, pending_invite or deleted.",
|
||||
"O11yDeprecatedUser.updatedAt": "UpdatedAt is when their record last changed.",
|
||||
"O11yDeprecatedUserOut.data": "Data is the member.",
|
||||
"O11yDeprecatedUserOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/user/preferences", zip.Doc{
|
||||
Description: "Lists every preference of the calling user, each with\nits current and default value. Viewer gate.",
|
||||
Fields: map[string]string{
|
||||
@@ -2355,75 +2138,6 @@ func init() {
|
||||
"O11yPreferenceOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/users", zip.Doc{
|
||||
Description: "Lists the caller's org members. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yUser.createdAt": "CreatedAt is when they joined.",
|
||||
"O11yUser.displayName": "DisplayName is what the console shows for them.",
|
||||
"O11yUser.email": "Email is their address.",
|
||||
"O11yUser.id": "ID is the user id.",
|
||||
"O11yUser.isRoot": "IsRoot marks the org's root user, which cannot be deleted or demoted.",
|
||||
"O11yUser.orgId": "OrgID is the org they belong to.",
|
||||
"O11yUser.status": "Status is their lifecycle state — active, pending_invite or deleted.",
|
||||
"O11yUser.updatedAt": "UpdatedAt is when their record last changed.",
|
||||
"O11yUsersOut.data": "Data holds the members.",
|
||||
"O11yUsersOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/users/:id", zip.Doc{
|
||||
Description: "Returns one org member together with every role they hold, by user\nid. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yRole.createdAt": "CreatedAt is when the role was created.",
|
||||
"O11yRole.description": "Description says what the role is for.",
|
||||
"O11yRole.id": "ID is the role id.",
|
||||
"O11yRole.name": "Name is the role's name.",
|
||||
"O11yRole.orgId": "OrgID is the org the role belongs to.",
|
||||
"O11yRole.type": "Type is how the role came to be — managed by the platform or custom.",
|
||||
"O11yRole.updatedAt": "UpdatedAt is when it last changed.",
|
||||
"O11yUserRole.createdAt": "CreatedAt is when it was assigned.",
|
||||
"O11yUserRole.id": "ID is the assignment's own id.",
|
||||
"O11yUserRole.role": "Role is the role itself.",
|
||||
"O11yUserRole.roleId": "RoleID is the role held.",
|
||||
"O11yUserRole.updatedAt": "UpdatedAt is when the assignment last changed.",
|
||||
"O11yUserRole.userId": "UserID is the user holding the role.",
|
||||
"O11yUserWithRoles.createdAt": "CreatedAt is when they joined.",
|
||||
"O11yUserWithRoles.displayName": "DisplayName is what the console shows for them.",
|
||||
"O11yUserWithRoles.email": "Email is their address.",
|
||||
"O11yUserWithRoles.id": "ID is the user id.",
|
||||
"O11yUserWithRoles.isRoot": "IsRoot marks the org's root user.",
|
||||
"O11yUserWithRoles.orgId": "OrgID is the org they belong to.",
|
||||
"O11yUserWithRoles.status": "Status is their lifecycle state — active, pending_invite or deleted.",
|
||||
"O11yUserWithRoles.updatedAt": "UpdatedAt is when their record last changed.",
|
||||
"O11yUserWithRoles.userRoles": "UserRoles are their role assignments.",
|
||||
"O11yUserWithRolesOut.data": "Data is the member.",
|
||||
"O11yUserWithRolesOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/users/:id/reset_password_tokens", zip.Doc{
|
||||
Description: "Returns the reset-password token a user already has; absent\none, the answer is a not-found rather than a fresh token. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yResetToken.expiresAt": "ExpiresAt is when it stops working.",
|
||||
"O11yResetToken.id": "ID is the grant's id.",
|
||||
"O11yResetToken.passwordId": "PasswordID is the password record it resets.",
|
||||
"O11yResetToken.token": "Token is the secret that redeems it.",
|
||||
"O11yResetTokenOut.data": "Data is the token.",
|
||||
"O11yResetTokenOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/users/:id/roles", zip.Doc{
|
||||
Description: "Returns every role one org member holds, by user id. Admin\ngate.",
|
||||
Fields: map[string]string{
|
||||
"O11yRole.createdAt": "CreatedAt is when the role was created.",
|
||||
"O11yRole.description": "Description says what the role is for.",
|
||||
"O11yRole.id": "ID is the role id.",
|
||||
"O11yRole.name": "Name is the role's name.",
|
||||
"O11yRole.orgId": "OrgID is the org the role belongs to.",
|
||||
"O11yRole.type": "Type is how the role came to be — managed by the platform or custom.",
|
||||
"O11yRole.updatedAt": "UpdatedAt is when it last changed.",
|
||||
"O11yRolesOut.data": "Data holds the roles.",
|
||||
"O11yRolesOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/users/me", zip.Doc{
|
||||
Description: "Returns the calling user together with every role they hold. Open\nto any authenticated caller.",
|
||||
Fields: map[string]string{
|
||||
@@ -3220,50 +2934,6 @@ func init() {
|
||||
"O11yDeploymentListOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/domains", zip.Doc{
|
||||
Description: "Claims an email domain for the org and configures how its\nusers sign in; the answer is the new domain's id. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yAttributeMapping.email": "Email is the key carrying the email; defaults to \"email\".",
|
||||
"O11yAttributeMapping.groups": "Groups is the key carrying the group list; defaults to \"groups\".",
|
||||
"O11yAttributeMapping.name": "Name is the key carrying the display name; defaults to \"name\".",
|
||||
"O11yAttributeMapping.role": "Role is the key carrying the role; defaults to \"role\".",
|
||||
"O11yAuthDomainConfig.googleAuthConfig": "Google is the Google provider's settings, when SSOType is google_auth.",
|
||||
"O11yAuthDomainConfig.oidcConfig": "OIDC is the OIDC provider's settings, when SSOType is oidc.",
|
||||
"O11yAuthDomainConfig.roleMapping": "RoleMapping maps the provider's groups onto roles for new users.",
|
||||
"O11yAuthDomainConfig.samlConfig": "SAML is the SAML provider's settings, when SSOType is saml.",
|
||||
"O11yAuthDomainConfig.ssoEnabled": "SSOEnabled turns enforced SSO on for the domain.",
|
||||
"O11yAuthDomainConfig.ssoType": "SSOType picks the provider — saml, google_auth or oidc.",
|
||||
"O11yCreated.id": "ID is the new record's id.",
|
||||
"O11yCreatedOut.data": "Data carries the id.",
|
||||
"O11yCreatedOut.status": "Status is \"success\".",
|
||||
"O11yGoogleConfig.allowedGroups": "AllowedGroups, when set, admits only members of these groups.",
|
||||
"O11yGoogleConfig.clientId": "ClientID is the OAuth application's id.",
|
||||
"O11yGoogleConfig.clientSecret": "ClientSecret is the OAuth application's secret.",
|
||||
"O11yGoogleConfig.domainToAdminEmail": "DomainToAdminEmail maps each Workspace domain to the admin the service\naccount impersonates; \"*\" is the fallback.",
|
||||
"O11yGoogleConfig.fetchGroups": "FetchGroups reads the user's Workspace groups for role mapping.",
|
||||
"O11yGoogleConfig.fetchTransitiveGroupMembership": "FetchTransitiveGroupMembership also reads groups held through other\ngroups.",
|
||||
"O11yGoogleConfig.insecureSkipEmailVerified": "InsecureSkipEmailVerified admits addresses Google has not verified.",
|
||||
"O11yGoogleConfig.redirectURI": "RedirectURI is the callback the flow returns to.",
|
||||
"O11yGoogleConfig.serviceAccountJson": "ServiceAccountJSON is the service-account credential used to read\ngroups, when FetchGroups is on.",
|
||||
"O11yOIDCConfig.claimMapping": "ClaimMapping names the token claims to read identity from.",
|
||||
"O11yOIDCConfig.clientId": "ClientID is the OAuth application's id.",
|
||||
"O11yOIDCConfig.clientSecret": "ClientSecret is the OAuth application's secret.",
|
||||
"O11yOIDCConfig.getUserInfo": "GetUserInfo also queries the userinfo endpoint, for providers whose id\ntokens are thin.",
|
||||
"O11yOIDCConfig.insecureSkipEmailVerified": "InsecureSkipEmailVerified admits addresses the provider has not\nverified.",
|
||||
"O11yOIDCConfig.issuer": "Issuer is the provider's issuer URL.",
|
||||
"O11yOIDCConfig.issuerAlias": "IssuerAlias overrides the issuer for providers whose discovery document\ndisagrees with their issuer URL.",
|
||||
"O11yPostableAuthDomain.config": "Config is the domain's SSO configuration.",
|
||||
"O11yPostableAuthDomain.name": "Name is the email domain being claimed, e.g. example.com.",
|
||||
"O11yRoleMapping.defaultRole": "DefaultRole is the role when no group mapping applies.",
|
||||
"O11yRoleMapping.groupMappings": "GroupMappings maps a provider group name to a role name.",
|
||||
"O11yRoleMapping.useRoleAttribute": "UseRoleAttribute reads the role straight from the provider's role claim\ninstead of the group mappings.",
|
||||
"O11ySAMLConfig.attributeMapping": "AttributeMapping names the assertion attributes to read identity from.",
|
||||
"O11ySAMLConfig.insecureSkipAuthNRequestsSigned": "InsecureSkipAuthNRequestsSigned skips signing outgoing AuthN requests,\nfor IdPs that refuse signed ones.",
|
||||
"O11ySAMLConfig.samlCert": "SamlCert is the IdP's signing certificate.",
|
||||
"O11ySAMLConfig.samlEntity": "SamlEntity is the IdP's entityID.",
|
||||
"O11ySAMLConfig.samlIdp": "SamlIdp is the IdP's single-sign-on endpoint.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/downtime_schedules", zip.Doc{
|
||||
Description: "Creates a planned maintenance window, answering with\nthe stored schedule. Editor gate.",
|
||||
Fields: map[string]string{
|
||||
@@ -3322,14 +2992,6 @@ func init() {
|
||||
"SavedView.extraData": "ExtraData is JSON encoded data used by frontend to store additional data",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/factor_password/forgot", zip.Doc{
|
||||
Description: "Starts the forgotten-password flow: the named user is mailed\na reset link. Unauthenticated by design, and deliberately quiet about\nwhether the address exists.",
|
||||
Fields: map[string]string{
|
||||
"O11yForgotPasswordIn.email": "Email is the address to mail the reset link to. Required.",
|
||||
"O11yForgotPasswordIn.frontendBaseURL": "FrontendBaseURL is the console origin the reset link is built on.",
|
||||
"O11yForgotPasswordIn.orgId": "OrgID is the org the address belongs to. Required.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/gateway/ingestion_keys", zip.Doc{
|
||||
Description: "Mints an ingestion key for the workspace, answering with\nthe created key. Editor gate.",
|
||||
Fields: map[string]string{
|
||||
@@ -3478,37 +3140,6 @@ func init() {
|
||||
"O11yIntegrationAck.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/invite", zip.Doc{
|
||||
Description: "Invites one person to the caller's org by email, with the role\nthey will hold when they accept. Deprecated in favor of creating users\ndirectly; kept because callers still hold it. Admin gate, enforced by the\nruntime this op relays to.",
|
||||
Fields: map[string]string{
|
||||
"O11yInvite.createdAt": "CreatedAt is when it was created.",
|
||||
"O11yInvite.email": "Email is the address it was sent to.",
|
||||
"O11yInvite.id": "ID is the invitation's id.",
|
||||
"O11yInvite.inviteLink": "InviteLink is the full link mailed to them.",
|
||||
"O11yInvite.name": "Name is the invitee's display name.",
|
||||
"O11yInvite.orgId": "OrgID is the org they are invited into.",
|
||||
"O11yInvite.role": "Role is the role the invitee will hold.",
|
||||
"O11yInvite.token": "Token is the secret that redeems it.",
|
||||
"O11yInvite.updatedAt": "UpdatedAt is when it last changed.",
|
||||
"O11yInviteIn.email": "Email is the address the invitation goes to.",
|
||||
"O11yInviteIn.frontendBaseUrl": "FrontendBaseUrl is the console origin the invite link is built on.",
|
||||
"O11yInviteIn.name": "Name is the invitee's display name.",
|
||||
"O11yInviteIn.role": "Role is the role they will hold on accepting — ADMIN, EDITOR or VIEWER.",
|
||||
"O11yInviteOut.data": "Data is the invitation.",
|
||||
"O11yInviteOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/invite/bulk", zip.Doc{
|
||||
Description: "Invites several people to the caller's org in one call,\nrefusing the whole batch when any email repeats. Deprecated alongside\ncreateInvite. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yAck.status": "Status is \"success\".",
|
||||
"O11yBulkInviteIn.invites": "Invites are the invitations to create; an email may appear only once.",
|
||||
"O11yInviteIn.email": "Email is the address the invitation goes to.",
|
||||
"O11yInviteIn.frontendBaseUrl": "FrontendBaseUrl is the console origin the invite link is built on.",
|
||||
"O11yInviteIn.name": "Name is the invitee's display name.",
|
||||
"O11yInviteIn.role": "Role is the role they will hold on accepting — ADMIN, EDITOR or VIEWER.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/jobs/list", zip.Doc{
|
||||
Description: "Lists Kubernetes jobs over a time range, each with the CPU and\nmemory its pods used against request and limit, desired-successful, active,\nfailed and successful pod counts, restarts and attributes; filterable,\ngroupable and paginated.",
|
||||
Fields: map[string]string{
|
||||
@@ -4214,39 +3845,6 @@ func init() {
|
||||
"QueryRangeRequest.variables": "Variables is the variables to use for the request.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/register", zip.Doc{
|
||||
Description: "Creates the FIRST organization and its admin user. It is open by\ndesign — there is nobody to be signed in as yet — and refuses once setup has\ncompleted, after which new users arrive by invitation only.\n\nOpen by design; the runtime's own gate is OpenAccess.",
|
||||
Fields: map[string]string{
|
||||
"O11yRegisterIn.email": "Email is the admin's email. Required.",
|
||||
"O11yRegisterIn.name": "Name is the admin's display name.",
|
||||
"O11yRegisterIn.orgDisplayName": "OrgDisplayName is the organization's display name.",
|
||||
"O11yRegisterIn.orgName": "OrgName is the organization's name.",
|
||||
"O11yRegisterIn.password": "Password is the admin's password.",
|
||||
"O11yRegisterOut.data": "Data is the user. The runtime answers register with the same user shape\nthe identity face reads, so it is the ONE O11yUser (identity.go) — a\ncreated user is a user, and the document names it once.",
|
||||
"O11yRegisterOut.status": "Status is \"success\".",
|
||||
"O11yUser.createdAt": "CreatedAt is when they joined.",
|
||||
"O11yUser.displayName": "DisplayName is what the console shows for them.",
|
||||
"O11yUser.email": "Email is their address.",
|
||||
"O11yUser.id": "ID is the user id.",
|
||||
"O11yUser.isRoot": "IsRoot marks the org's root user, which cannot be deleted or demoted.",
|
||||
"O11yUser.orgId": "OrgID is the org they belong to.",
|
||||
"O11yUser.status": "Status is their lifecycle state — active, pending_invite or deleted.",
|
||||
"O11yUser.updatedAt": "UpdatedAt is when their record last changed.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/resetPassword", zip.Doc{
|
||||
Description: "Sets a new password for whoever the reset token was minted\nfor, consuming the token. Unauthenticated: the token is the proof.",
|
||||
Fields: map[string]string{
|
||||
"O11yResetPasswordIn.password": "Password is the new password.",
|
||||
"O11yResetPasswordIn.token": "Token is the reset-password token authorizing the change.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/reset_password_tokens/verify", zip.Doc{
|
||||
Description: "Checks that a reset-password token exists and has not\nexpired, without consuming it. Unauthenticated: the token is the proof.",
|
||||
Fields: map[string]string{
|
||||
"O11yResetTokenRef.token": "Token is the reset-password token.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/roles", zip.Doc{
|
||||
Description: "Creates a custom role in the caller's org from a name, an optional\ndescription and the transaction groups it grants, answering the new role's id.\n\nNames are lowercase letters and hyphens only, and may not start with the\nreserved managed-role prefix; the runtime refuses anything else.",
|
||||
Fields: map[string]string{
|
||||
@@ -4434,32 +4032,6 @@ func init() {
|
||||
"O11yServicesOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/sessions/email_password", zip.Doc{
|
||||
Description: "Signs a user in with email and password and\nanswers with the session's token pair. Unauthenticated: this call is how\nauthentication begins.",
|
||||
Fields: map[string]string{
|
||||
"O11yEmailPasswordSessionIn.email": "Email is the account's address. Required.",
|
||||
"O11yEmailPasswordSessionIn.orgId": "OrgID picks the org to sign into when the address belongs to several.",
|
||||
"O11yEmailPasswordSessionIn.password": "Password is the account's password. Required.",
|
||||
"O11yToken.accessToken": "AccessToken authenticates requests until it expires.",
|
||||
"O11yToken.expiresIn": "ExpiresIn is the access token's lifetime in seconds.",
|
||||
"O11yToken.refreshToken": "RefreshToken buys the next pair via rotateSession.",
|
||||
"O11yToken.tokenType": "TokenType is how to present the access token, e.g. bearer.",
|
||||
"O11yTokenOut.data": "Data is the pair.",
|
||||
"O11yTokenOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/sessions/rotate", zip.Doc{
|
||||
Description: "Exchanges a refresh token for a fresh token pair, retiring the\nold pair. The access token being rotated identifies the session.",
|
||||
Fields: map[string]string{
|
||||
"O11yRotateSessionIn.refreshToken": "RefreshToken is the refresh token being redeemed.",
|
||||
"O11yToken.accessToken": "AccessToken authenticates requests until it expires.",
|
||||
"O11yToken.expiresIn": "ExpiresIn is the access token's lifetime in seconds.",
|
||||
"O11yToken.refreshToken": "RefreshToken buys the next pair via rotateSession.",
|
||||
"O11yToken.tokenType": "TokenType is how to present the access token, e.g. bearer.",
|
||||
"O11yTokenOut.data": "Data is the pair.",
|
||||
"O11yTokenOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/settings/apdex", zip.Doc{
|
||||
Description: "Sets one service's apdex threshold and the status codes excluded\nfrom its score.\n\nAdmin only, as the mux tree has always gated it (AdminAccess); the runtime's\nown gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
@@ -4884,26 +4456,6 @@ func init() {
|
||||
"O11yFieldSetting.type": "Type is where the field lives: attributes or resources. Required.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/users", zip.Doc{
|
||||
Description: "Creates a member of the caller's org in the pending-invite state\nand mails them their invitation; the answer is the new user's id. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yCreated.id": "ID is the new record's id.",
|
||||
"O11yCreatedOut.data": "Data carries the id.",
|
||||
"O11yCreatedOut.status": "Status is \"success\".",
|
||||
"O11yPostableUser.displayName": "DisplayName is the new member's display name.",
|
||||
"O11yPostableUser.email": "Email is the new member's address. Required.",
|
||||
"O11yPostableUser.frontendBaseUrl": "FrontendBaseUrl is the console origin the invite link is built on.",
|
||||
"O11yPostableUser.userRoles": "UserRoles are the roles the member starts with, each by id.",
|
||||
"O11yRoleID.id": "ID is the role id.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/users/:id/roles", zip.Doc{
|
||||
Description: "Assigns a role, by role name, to one org member — someone else,\nnever the caller. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yAck.status": "Status is \"success\".",
|
||||
"O11ySetRoleIn.name": "Name is the role name to assign.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/variables/query", zip.Doc{
|
||||
Description: "Evaluates a dashboard variable query and returns the values the\nvariable may take.\n\nCallers need the viewer role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
@@ -5103,46 +4655,6 @@ func init() {
|
||||
"O11yPublicDashboardWriteIn.id": "ID is the dashboard id from the path.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/domains/:id", zip.Doc{
|
||||
Description: "Replaces one auth domain's SSO configuration, by id. Admin\ngate.",
|
||||
Fields: map[string]string{
|
||||
"O11yAttributeMapping.email": "Email is the key carrying the email; defaults to \"email\".",
|
||||
"O11yAttributeMapping.groups": "Groups is the key carrying the group list; defaults to \"groups\".",
|
||||
"O11yAttributeMapping.name": "Name is the key carrying the display name; defaults to \"name\".",
|
||||
"O11yAttributeMapping.role": "Role is the key carrying the role; defaults to \"role\".",
|
||||
"O11yAuthDomainConfig.googleAuthConfig": "Google is the Google provider's settings, when SSOType is google_auth.",
|
||||
"O11yAuthDomainConfig.oidcConfig": "OIDC is the OIDC provider's settings, when SSOType is oidc.",
|
||||
"O11yAuthDomainConfig.roleMapping": "RoleMapping maps the provider's groups onto roles for new users.",
|
||||
"O11yAuthDomainConfig.samlConfig": "SAML is the SAML provider's settings, when SSOType is saml.",
|
||||
"O11yAuthDomainConfig.ssoEnabled": "SSOEnabled turns enforced SSO on for the domain.",
|
||||
"O11yAuthDomainConfig.ssoType": "SSOType picks the provider — saml, google_auth or oidc.",
|
||||
"O11yGoogleConfig.allowedGroups": "AllowedGroups, when set, admits only members of these groups.",
|
||||
"O11yGoogleConfig.clientId": "ClientID is the OAuth application's id.",
|
||||
"O11yGoogleConfig.clientSecret": "ClientSecret is the OAuth application's secret.",
|
||||
"O11yGoogleConfig.domainToAdminEmail": "DomainToAdminEmail maps each Workspace domain to the admin the service\naccount impersonates; \"*\" is the fallback.",
|
||||
"O11yGoogleConfig.fetchGroups": "FetchGroups reads the user's Workspace groups for role mapping.",
|
||||
"O11yGoogleConfig.fetchTransitiveGroupMembership": "FetchTransitiveGroupMembership also reads groups held through other\ngroups.",
|
||||
"O11yGoogleConfig.insecureSkipEmailVerified": "InsecureSkipEmailVerified admits addresses Google has not verified.",
|
||||
"O11yGoogleConfig.redirectURI": "RedirectURI is the callback the flow returns to.",
|
||||
"O11yGoogleConfig.serviceAccountJson": "ServiceAccountJSON is the service-account credential used to read\ngroups, when FetchGroups is on.",
|
||||
"O11yOIDCConfig.claimMapping": "ClaimMapping names the token claims to read identity from.",
|
||||
"O11yOIDCConfig.clientId": "ClientID is the OAuth application's id.",
|
||||
"O11yOIDCConfig.clientSecret": "ClientSecret is the OAuth application's secret.",
|
||||
"O11yOIDCConfig.getUserInfo": "GetUserInfo also queries the userinfo endpoint, for providers whose id\ntokens are thin.",
|
||||
"O11yOIDCConfig.insecureSkipEmailVerified": "InsecureSkipEmailVerified admits addresses the provider has not\nverified.",
|
||||
"O11yOIDCConfig.issuer": "Issuer is the provider's issuer URL.",
|
||||
"O11yOIDCConfig.issuerAlias": "IssuerAlias overrides the issuer for providers whose discovery document\ndisagrees with their issuer URL.",
|
||||
"O11yRoleMapping.defaultRole": "DefaultRole is the role when no group mapping applies.",
|
||||
"O11yRoleMapping.groupMappings": "GroupMappings maps a provider group name to a role name.",
|
||||
"O11yRoleMapping.useRoleAttribute": "UseRoleAttribute reads the role straight from the provider's role claim\ninstead of the group mappings.",
|
||||
"O11ySAMLConfig.attributeMapping": "AttributeMapping names the assertion attributes to read identity from.",
|
||||
"O11ySAMLConfig.insecureSkipAuthNRequestsSigned": "InsecureSkipAuthNRequestsSigned skips signing outgoing AuthN requests,\nfor IdPs that refuse signed ones.",
|
||||
"O11ySAMLConfig.samlCert": "SamlCert is the IdP's signing certificate.",
|
||||
"O11ySAMLConfig.samlEntity": "SamlEntity is the IdP's entityID.",
|
||||
"O11ySAMLConfig.samlIdp": "SamlIdp is the IdP's single-sign-on endpoint.",
|
||||
"O11yUpdatableAuthDomain.config": "Config is the SSO configuration to store.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/downtime_schedules/:id", zip.Doc{
|
||||
Description: "Replaces a planned maintenance window, by id. Editor gate.",
|
||||
})
|
||||
@@ -5310,66 +4822,18 @@ func init() {
|
||||
"O11yFunnelStepsUpdateIn.timestamp": "Timestamp is when the change was made, as a millisecond epoch.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/user/:id", zip.Doc{
|
||||
Description: "Renames one org member and may move their legacy role,\nanswering with the updated record. Admins may update anyone; a non-admin\nonly themselves (the runtime's self-access gate).",
|
||||
Fields: map[string]string{
|
||||
"O11yDeprecatedUser.createdAt": "CreatedAt is when they joined.",
|
||||
"O11yDeprecatedUser.displayName": "DisplayName is what the console shows for them.",
|
||||
"O11yDeprecatedUser.email": "Email is their address.",
|
||||
"O11yDeprecatedUser.id": "ID is the user id.",
|
||||
"O11yDeprecatedUser.isRoot": "IsRoot marks the org's root user.",
|
||||
"O11yDeprecatedUser.orgId": "OrgID is the org they belong to.",
|
||||
"O11yDeprecatedUser.role": "Role is their legacy role — ADMIN, EDITOR or VIEWER.",
|
||||
"O11yDeprecatedUser.status": "Status is their lifecycle state — active, pending_invite or deleted.",
|
||||
"O11yDeprecatedUser.updatedAt": "UpdatedAt is when their record last changed.",
|
||||
"O11yDeprecatedUserOut.data": "Data is the member.",
|
||||
"O11yDeprecatedUserOut.status": "Status is \"success\".",
|
||||
"O11yDeprecatedUserUpdate.displayName": "DisplayName is the new display name; empty leaves it unchanged.",
|
||||
"O11yDeprecatedUserUpdate.role": "Role is the legacy role to move to — ADMIN, EDITOR or VIEWER; empty\nleaves it unchanged.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/user/preferences/:name", zip.Doc{
|
||||
Description: "Sets one preference of the calling user, by name.\nViewer gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yUpdatablePreference.value": "Value is the value to set; its JSON type must match the preference's\ndeclared value type.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/users/:id", zip.Doc{
|
||||
Description: "Renames one org member, by user id — someone else, never the\ncaller, who renames themselves through updateMyUser. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yUserUpdate.displayName": "DisplayName is the new display name.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/users/:id/reset_password_tokens", zip.Doc{
|
||||
Description: "Creates or regenerates a user's reset-password token: a\nlive token is returned as it is, an expired one is replaced. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
"O11yResetToken.expiresAt": "ExpiresAt is when it stops working.",
|
||||
"O11yResetToken.id": "ID is the grant's id.",
|
||||
"O11yResetToken.passwordId": "PasswordID is the password record it resets.",
|
||||
"O11yResetToken.token": "Token is the secret that redeems it.",
|
||||
"O11yResetTokenOut.data": "Data is the token.",
|
||||
"O11yResetTokenOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/users/me", zip.Doc{
|
||||
Description: "Renames the calling user. Open to any authenticated caller.",
|
||||
Fields: map[string]string{
|
||||
"O11yUpdatableUser.displayName": "DisplayName is the new display name.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/users/me/dashboards/:id/pins", zip.Doc{
|
||||
Description: "Pins a dashboard for the calling user. A user can pin at most ten\ndashboards; pinning at the limit refuses with the runtime's conflict. Re-pinning\nan already-pinned dashboard is a no-op success. Pinning mutates only the caller's\npin list, not the dashboard, so a viewer may pin what a viewer may read.\n\nCallers need the viewer role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
"O11yDashboardIDIn.id": "ID is the resource id from the path.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/users/me/factor_password", zip.Doc{
|
||||
Description: "Replaces the calling user's password, refusing when the old\none does not match. Open to any authenticated caller.",
|
||||
Fields: map[string]string{
|
||||
"O11yChangePasswordIn.newPassword": "NewPassword is the password to set.",
|
||||
"O11yChangePasswordIn.oldPassword": "OldPassword is the current password; the change is refused when it does\nnot match.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/zeus/hosts", zip.Doc{
|
||||
Description: "Records the deployment's host in Zeus, overwriting any prior one.\nAdmin gate.",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package o11y_test
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/o11y"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// A DESCRIPTION IS ADDRESSED, and an address nothing declares is not inert.
|
||||
//
|
||||
// zip keeps its doc registry as a package-level map keyed by "METHOD path", so a
|
||||
// Describe outlives the declaration it was generated from. In the community
|
||||
// binary that only publishes prose for an op that is not there. In a composed
|
||||
// host it is worse: the host serving that same address gets THIS module's
|
||||
// description and field names attached to ITS operation, which is a document
|
||||
// that lies about somebody else's route.
|
||||
//
|
||||
// zipdoc regenerates from the registrations, so what it would emit is exactly
|
||||
// the set below. This is the assertion that the file in the tree still is that.
|
||||
func TestEveryDescriptionHasAnAddress(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
if err := o11y.Use(app); err != nil {
|
||||
t.Fatalf("Use: %v", err)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, r := range app.Fiber().GetRoutes(true) {
|
||||
if r.Method == "HEAD" || r.Method == "OPTIONS" {
|
||||
continue
|
||||
}
|
||||
served[r.Method+" "+zip.Template(r.Path)] = true
|
||||
}
|
||||
|
||||
// zipdoc writes a path parameter as :name; a route table renders it {name}.
|
||||
param := regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "zipdoc_gen.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse zipdoc_gen.go: %v", err)
|
||||
}
|
||||
described := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Describe" {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
addr, err := strconv.Unquote(lit.Value)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
described++
|
||||
if !served[param.ReplaceAllString(addr, "{$1}")] {
|
||||
t.Errorf("zipdoc_gen.go describes %q, which this module does not declare", addr)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// The other direction. Only a TYPED op carries prose — an escape hatch and a
|
||||
// native probe are addresses without a document — so the registry, not the
|
||||
// route table, is what a description count has to equal.
|
||||
if ops := len(app.Registry()); described != ops {
|
||||
t.Errorf("%d descriptions for %d typed ops — a typed op with no prose publishes a bare schema", described, ops)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user