identity: o11y stops answering a question Hanzo IAM already answered

Thirty-four typed ops, twenty-four runtime user routes, seven session routes,
five SSO-domain routes and the three sign-in callbacks are deleted, with the
packages behind them: pkg/authn (password + Google callback + the authn store),
pkg/tokenizer (o11y minting its own access and refresh tokens), the tokenizer
identN resolver, pkg/modules/session, pkg/modules/authdomain, the factor_password
and invite types, and every credential method on the user store.

GET /v1/o11y/users/me is CONVERTED, not deleted. The console cannot render
without knowing who it is rendering for, and that is the one identity question
o11y can answer from what the edge already told it. It now reads the CLAIMS
rather than SELECTing a row, so o11y's own bookkeeping stops being a
precondition for rendering: a person the gateway authenticated whose row was
missing used to get 'user not found' on the very call the console uses as its
identity provider, and the whole console was unreachable. The identity was never
in doubt; the row was, and the row is not the identity. impluser.NewHandler now
takes no store at all, so the coupling is gone at the type level.

The user PROJECTION row stays. It is not an identity: it is what o11y's own
foreign keys point at — a dashboard's author, a saved view, a preference, a role
grant — written by iamidentn on first sight of an IAM subject, keyed by IAM's own
subject id. Deleting it would take user_role with it, and user_role is what
localauthz rehydrates every grant from.

localauthz: Start's rehydration is now PINNED BY A TEST OF START. All four
existing tests called p.rehydrate directly and all four stayed green when the
call was deleted from Start — a rehydration nothing invokes is a lockout with a
passing suite. TestStartRehydrates drives Start and goes red under exactly that
mutation.

The root-user service no longer reconciles a password onto the row it seeds:
nothing reads one. user::password::* and user::root::password are gone from the
config.

Counted, because arithmetic is the only thing that catches a deletion that left
the door standing: 367 -> 330 routes on the zip mount (353/11/3 -> 319/8/3), 233
-> 197 on the apiserver, and the anonymous-exemption table loses eleven
credential surfaces. identity_test.go asks the router for each of the
thirty-seven deleted method+paths by name.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Hanzo Dev
2026-08-02 14:12:43 -07:00
parent c6950210e4
commit 89854a68ae
83 changed files with 727 additions and 10028 deletions
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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 door 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/o11y/api/proj/envelope/",
+72 -952
View File
File diff suppressed because it is too large Load Diff
+116 -192
View File
@@ -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 door 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.SetHandler(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.SetHandler(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.Fiber().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 next door 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.SetHandler(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)
}
}
}
+17 -20
View File
@@ -41,8 +41,8 @@ import (
// with a clear error rather than pretending.
//
// EVERY ROUTE IS NAMED. There is no /v1/o11y/* catch-all any more. The runtime
// registers 367 method+path pairs; 356 of them are typed ops declared in the
// slice files below, and the remaining 11 are registered one by one in
// registers 331 method+path pairs; 323 of them are typed ops declared in the
// slice files below, and the remaining 8 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
@@ -74,11 +74,11 @@ func Mount(app *zip.App) 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,
@@ -150,12 +150,12 @@ func Mount(app *zip.App) 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 eleven that cannot be typed, each named and justified.
// And the eight that cannot be typed, each named and justified.
mountHatches(app)
return nil
}
// mountHatches registers the ELEVEN routes that cannot be typed ops, one route
// mountHatches registers the EIGHT 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.
@@ -167,10 +167,16 @@ func Mount(app *zip.App) error {
// than being absent from it — a generated client that trusts a false contract
// fails at the customer, not at review.
//
// Two of these eleven were not reachable AT ALL from the composed binary before
// Two of these eight were not reachable AT ALL from the composed binary before
// this pass: /ws/query_progress and the two /v1/sentry ingest routes sit outside
// /v1/o11y, so the old catch-all never saw them. That is the second thing a
// wildcard hides — not just which routes are un-typed, but which are missing.
//
// THREE REDIRECT HATCHES LEFT THIS LIST with the identity system: the Google,
// OIDC and SAML sign-in callbacks. They were the last routes here whose reason
// for existing was authentication, and they are not hatched now, they are
// deleted — the runtime behind them is gone, and a hatch onto nothing is a 503
// that looks like a door.
func mountHatches(app *zip.App) {
h := zip.AdaptNetHTTP(handlerAdapter{})
@@ -185,16 +191,7 @@ func mountHatches(app *zip.App) {
app.Get("/ws/query_progress", h) // the same read over a websocket; the Upgrade IS the contract
app.Post(o11yRoot+"/export_raw_data", h) // 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.
app.Get(o11yRoot+"/complete/google", h) // Google OIDC callback → 303 to the console
app.Get(o11yRoot+"/complete/oidc", h) // generic OIDC callback → 303
app.Post(o11yRoot+"/complete/saml", h) // SAML assertion consumer → 303
// ── 3. A FOREIGN PROTOCOL WE RECEIVE ─────────────────────────────────────
// ── 2. A FOREIGN PROTOCOL WE RECEIVE ─────────────────────────────────────
// Sentry-compatible ingest. 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 than a Hanzo session. The /api/ segment is NOT ours to
-92
View File
@@ -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),
}))
}
+1 -15
View File
@@ -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)
@@ -296,6 +282,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},
}
}
+10 -6
View File
@@ -13,7 +13,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"
@@ -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"
@@ -44,7 +42,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
@@ -52,7 +50,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
@@ -64,8 +70,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 }{},
-127
View File
@@ -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{},
}))
}
+27 -373
View File
@@ -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()}},
}))
}
-28
View File
@@ -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
}
+41
View File
@@ -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)
}
-5
View File
@@ -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
-1
View File
@@ -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},
),
-19
View File
@@ -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")
}
+3 -4
View File
@@ -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)
-14
View File
@@ -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 {
-115
View File
@@ -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
}
-50
View File
@@ -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
}
-173
View File
@@ -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()
}
-237
View File
@@ -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
}
-54
View File
@@ -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
View File
@@ -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
-159
View File
@@ -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 {
+52 -605
View File
@@ -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,
})
}
+60 -119
View File
@@ -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)
}
}
+38 -125
View File
@@ -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
-409
View File
@@ -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{}
+6 -50
View File
@@ -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
View File
@@ -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
-27
View File
@@ -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
}
-13
View File
@@ -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
+1 -3
View File
@@ -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
View File
@@ -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 (o11y_issues lifecycle) and trace detail (o11y_traces 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),
+1 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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),
)
}
+1 -2
View File
@@ -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() {
-27
View File
@@ -62,7 +62,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"
@@ -1158,32 +1157,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
+7 -7
View File
@@ -13,7 +13,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
@@ -71,20 +71,20 @@ func operations(t *testing.T, doc map[string]any) int {
return n
}
// 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 := len(app.MCPTools()); got != 353 {
t.Fatalf("the served router offers %d MCP tools, want 353", got)
if got := len(app.MCPTools()); got != 319 {
t.Fatalf("the served router offers %d MCP tools, want 319", got)
}
}
+3 -3
View File
@@ -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
-2
View File
@@ -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))
}
}
-122
View File
@@ -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
}
-27
View File
@@ -1,27 +0,0 @@
package jwttokenizer
import (
"github.com/hanzoai/o11y/pkg/errors"
"github.com/golang-jwt/jwt/v5"
)
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
}
-307
View File
@@ -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()
}
-484
View File
@@ -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()
}
-43
View File
@@ -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
}
-774
View File
@@ -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
}
-135
View File
@@ -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)
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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",
-174
View File
@@ -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)
}
-230
View File
@@ -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
}
-38
View File
@@ -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
}
-97
View File
@@ -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
}
+81
View File
@@ -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,
}
}
-1
View File
@@ -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")}
-141
View File
@@ -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
}
-62
View File
@@ -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
}
-56
View File
@@ -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
}
-68
View File
@@ -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
}
-263
View File
@@ -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
}
-231
View File
@@ -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)
}
-14
View File
@@ -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())
})
}
+4 -7
View File
@@ -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,
}
-83
View File
@@ -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
}
-55
View File
@@ -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
View File
@@ -58,7 +58,6 @@ func mountPlatform(app *zip.App) {
zip.Get(g, "/version", version)
zip.Get(g, "/health", health)
zip.Get(g, "/disks", disks)
zip.Post(g, "/register", register)
zip.Post(g, "/span_percentile", spanPercentile)
zip.Post(g, "/query_filter/analyze", analyzeQueryFilter)
zip.Get(g, "/filter_suggestions", filterSuggestions)
@@ -176,16 +175,6 @@ func disks(ctx context.Context, _ *struct{}) (*O11yDiskList, error) {
return out, relay(ctx, http.MethodGet, o11yRoot+"/disks", 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, http.MethodPost, o11yRoot+"/register", 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.
//
@@ -423,20 +412,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.
@@ -635,16 +610,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".
+14 -12
View File
@@ -57,11 +57,18 @@ func assertRoutes(t *testing.T, want map[string]bool, prefix string) {
}
}
// THE WHOLE SURFACE, counted. 367 is the number of method+path pairs the o11y
// THE WHOLE SURFACE, counted. 330 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 367 — 353 as
// and pkg/apiserver/o11yapiserver/*.go). Mount registers the same 330 — 319 as
// typed ops that carry a named In, a named Out and their prose into the
// document, 11 as named escape hatches, 3 as native service probes.
// document, 8 as named escape hatches, 3 as native service probes.
//
// It was 367 (353/11/3). Thirty-seven routes left with the duplicate identity
// system: thirty-four typed ops (invites, passwords, reset tokens, member
// administration, roles-on-users, sessions, SSO domains, self-registration) and
// the three redirect hatches that were the sign-in callbacks. Hanzo IAM serves
// all of it, and a route that is merely unrouted is not deleted — these have no
// handler, no store and no table left behind them.
//
// 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 +79,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 = 11 // mount.go mountHatches, each with its reason
wantTyped = 319 // typed ops: in the OpenAPI document and the MCP tool list
wantHatches = 8 // 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 +154,7 @@ func setRuntime(t *testing.T, fn http.HandlerFunc) {
t.Cleanup(func() { o11y.SetHandler(nil) })
}
// THE ELEVEN. Each hatch is registered at its exact method and path, and each
// THE EIGHT. 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,17 +164,12 @@ func TestHatchesDelegateVerbatim(t *testing.T) {
{http.MethodGet, "/v1/o11y/query_progress"},
{http.MethodGet, "/ws/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/o11y/api/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"},
{http.MethodPost, "/v1/o11y/api/6ba7b810-9dad-11d1-80b4-00c04fd430c8/store/"},
{http.MethodPost, "/v1/sentry/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"},
{http.MethodPost, "/v1/sentry/6ba7b810-9dad-11d1-80b4-00c04fd430c8/store/"},
}
if len(hatches) != 11 {
if len(hatches) != 8 {
t.Fatalf("the census itself is wrong: %d", len(hatches))
}