Files
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

140 lines
4.6 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package oauthhanzo
import (
"encoding/json"
"errors"
"io"
"strings"
"github.com/hanzoai/authz"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/mlog"
"github.com/hanzoteam/server/server/public/shared/request"
"github.com/hanzoteam/server/server/v8/einterfaces"
)
// Provider reads Hanzo IAM's UserInfo response. The claim set is the one
// hanzo.id advertises at /.well-known/openid-configuration.
type Provider struct{}
type IAMUser struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
DisplayName string `json:"displayName"`
Picture string `json:"picture"`
// Owner is the IAM org slug and the tenant boundary. Organization is its
// display name, which is presentational only -- never key on it.
Owner string `json:"owner"`
Organization string `json:"organization"`
// Orgs is the set of orgs this person is a member of, home org first. Owner
// says where they are ANCHORED; this says what they may act in, and the two
// are different questions -- an operator is anchored in an ordinary brand org
// and holds the reserved org alongside it.
Orgs []authz.Membership `json:"orgs"`
}
func init() {
einterfaces.RegisterOAuthProvider(model.UserAuthServiceHanzo, &Provider{})
}
func (u *IAMUser) IsValid() error {
if u.Sub == "" {
return errors.New("hanzo iam: subject claim is empty")
}
if u.Email == "" {
return errors.New("hanzo iam: email claim is empty")
}
if u.Owner == "" {
return errors.New("hanzo iam: owner claim is empty, cannot place the user in a tenant")
}
return nil
}
func userFromIAMUser(logger mlog.LoggerIFace, iu *IAMUser, settings *model.SSOSettings) *model.User {
user := &model.User{}
name := iu.PreferredUsername
if name == "" {
name = iu.Name
}
// A preferred_username may be an address; the local part is the username.
user.Username = model.CleanUsername(logger, strings.Split(name, "@")[0])
display := iu.DisplayName
if display == "" {
display = iu.Name
}
if first, last, ok := strings.Cut(display, " "); ok {
user.FirstName, user.LastName = first, last
} else {
user.FirstName = display
}
user.Email = strings.ToLower(iu.Email)
user.EmailVerified = iu.EmailVerified
// sub is IAM's stable identifier. Email and username both move; sub does not.
sub := iu.Sub
user.AuthData = &sub
user.AuthService = model.UserAuthServiceHanzo
user.SetProp(model.UserPropOrg, iu.Owner)
// Who administers this server is IAM's answer, and it is asked through IAM's
// own predicate rather than restated here: platform authority is membership of
// the reserved org, held at any position, which is not the same question as
// which org someone is anchored in. Re-deriving it locally is how a check that
// reads correctly comes to disagree with the issuer.
//
// An identity carrying no membership set -- a machine token, or an IAM that
// does not send `orgs` -- is not an operator, so this fails closed.
user.Roles = model.SystemUserRoleId
if (&authz.Claims{Orgs: iu.Orgs}).PlatformSudo() {
user.Roles = model.SystemAdminRoleId + " " + model.SystemUserRoleId
}
return user
}
func (p *Provider) GetUserFromJSON(rctx request.CTX, data io.Reader, tokenUser *model.User, settings *model.SSOSettings) (*model.User, error) {
var iu IAMUser
if err := json.NewDecoder(data).Decode(&iu); err != nil {
return nil, err
}
if err := iu.IsValid(); err != nil {
return nil, err
}
return userFromIAMUser(rctx.Logger(), &iu, settings), nil
}
func (p *Provider) GetSSOSettings(_ request.CTX, config *model.Config, service string) (*model.SSOSettings, error) {
return &config.HanzoSettings, nil
}
func (p *Provider) GetUserFromIdToken(_ request.CTX, idToken string) (*model.User, error) {
return nil, nil
}
func (p *Provider) IsSameUser(_ request.CTX, dbUser, oauthUser *model.User) bool {
if dbUser.AuthData != nil && oauthUser.AuthData != nil && *dbUser.AuthData == *oauthUser.AuthData {
return true
}
// An account already carrying a different subject stays with it: an
// identity is never moved from one subject to another.
if dbUser.AuthService == model.UserAuthServiceHanzo {
return false
}
// hanzo.id issues every identity here and vouches for the address, so the
// account holding it is this user, whatever it signed in with before.
return oauthUser.Email != "" && dbUser.Email == oauthUser.Email
}