Skip to content
This repository has been archived by the owner on Jan 28, 2021. It is now read-only.

auth: add authentication and authorization interface #496

Merged
merged 12 commits into from
Oct 25, 2018
53 changes: 53 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package auth

import (
"strings"

"gopkg.in/src-d/go-vitess.v1/mysql"
)

// Permission holds permissions required by a query or grated to a user.
type Permission int

const (
// ReadPerm means that it reads.
ReadPerm Permission = 1 << iota
// WritePerm means that it writes.
WritePerm
)

var (
// AllPermissions hold all defined permissions.
AllPermissions = ReadPerm | WritePerm
// DefaultPermissions are the permissions granted to a user if not defined.
DefaultPermissions = ReadPerm

// PermissionNames is used to translate from human and machine
// representations.
PermissionNames = map[string]Permission{
"read": ReadPerm,
"write": WritePerm,
}

// ErrNoPermission is returned when the user lacks needed permissions.
ErrNoPermission = "user does not have permission: %s"
)

// String returns all the permissions set to on.
func (p Permission) String() string {
var str []string
for k, v := range PermissionNames {
if p&v != 0 {
str = append(str, k)
}
}

return strings.Join(str, ", ")
}

// Auth interface provides mysql authentication methods and permission checking
// for users.
type Auth interface {
Mysql() mysql.AuthServer
Allowed(user string, permission Permission) error
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe return (bool, error)?

This would allow to differentiate from not having permissions and failing to check permissions. The former would probably produce a warning audit log (when we have audit logs), the second would probably also produce a regular error log.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(bool, error) sounds to me like a fuzzy logic:
(true, nil)
(false, nil)
(false, err)

and hopefully it's not possible to have:
(true, err)

It's like returning *bool it can give you (nil, *true, *false)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kuba-- It's a common pattern in Go, also in our own codebase. (true, err) is usually not relevant since the value would not be even checked if err != nil.

But alternatively, you can keep just err as return value and use a special error kind ErrNotAuthorized to differentiate from other errors.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smola - totally understand, just as I mentioned, personally I don't like this pattern, because have a feeling that it's a boolean logic with extra dimension.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm moving to use go-errors and returning ErrNotAuthorized so is easier to tell apart.

Copy link
Collaborator

@smola smola Oct 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, add godoc to the interface. Specially a mention to errors returned fo Allowed. My guess is that the one that is not expected to be implementation-depdendent is ErrNoAuthorized.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}
142 changes: 142 additions & 0 deletions auth/native.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package auth

import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"regexp"
"strings"

"gopkg.in/src-d/go-vitess.v1/mysql"
)

var regNative = regexp.MustCompile(`^\*[0-9A-F]{40}$`)

// ErrUnknownPermission happens when a user permission is not defined.
const ErrUnknownPermission = "error parsing user file, unknown permission %s"

// nativeUser holds information about credentials and permissions for user.
type nativeUser struct {
Name string
Password string
JSONPermissions []string `json:"Permissions"`
Permissions Permission
}

// Allowed checks if the user has certain permission.
func (u nativeUser) Allowed(p Permission) error {
if u.Permissions&p == p {
return nil
}

// permissions needed but not granted to the user
p2 := (^u.Permissions) & p

return fmt.Errorf(ErrNoPermission, p2)
}

// NativePassword generates a mysql_native_password string.
func NativePassword(password string) string {
if len(password) == 0 {
return ""
}

// native = sha1(sha1(password))

hash := sha1.New()
hash.Write([]byte(password))
s1 := hash.Sum(nil)

hash.Reset()
hash.Write(s1)
s2 := hash.Sum(nil)

s := strings.ToUpper(hex.EncodeToString(s2))

return fmt.Sprintf("*%s", s)
}

// Native holds mysql_native_password users.
type Native struct {
users map[string]nativeUser
}

// NewNativeSingle creates a NativeAuth with a single user.
func NewNativeSingle(name, password string) *Native {
users := make(map[string]nativeUser)
users[name] = nativeUser{
Name: name,
Password: NativePassword(password),
Permissions: AllPermissions,
}

return &Native{users}
}

// NewNativeFile creates a NativeAuth and loads users from a JSON file.
func NewNativeFile(file string) (*Native, error) {
var data []nativeUser

raw, err := ioutil.ReadFile(file)
if err != nil {
return nil, err
}

if err := json.Unmarshal(raw, &data); err != nil {
return nil, err
}

users := make(map[string]nativeUser)
for _, u := range data {
_, ok := users[u.Name]
if ok {
return nil, fmt.Errorf("duplicate user: %s", u.Name)
}

if !regNative.MatchString(u.Password) {
u.Password = NativePassword(u.Password)
}

if len(u.JSONPermissions) == 0 {
u.Permissions = DefaultPermissions
}

for _, p := range u.JSONPermissions {
perm, ok := PermissionNames[strings.ToLower(p)]
if !ok {
return nil, fmt.Errorf(ErrUnknownPermission, p)
}

u.Permissions |= perm
}

users[u.Name] = u
}

return &Native{users}, nil
}

// Mysql implements Auth interface.
func (s *Native) Mysql() mysql.AuthServer {
auth := mysql.NewAuthServerStatic()

for k, v := range s.users {
auth.Entries[k] = []*mysql.AuthServerStaticEntry{
{MysqlNativePassword: v.Password},
}
}

return auth
}

// Allowed implements Auth interface.
func (s *Native) Allowed(name string, permission Permission) error {
u, ok := s.users[name]
if !ok {
return fmt.Errorf(ErrNoPermission, permission)
}

return u.Allowed(permission)
}
16 changes: 16 additions & 0 deletions auth/none.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package auth

import "gopkg.in/src-d/go-vitess.v1/mysql"

// None is a Auth method that always succeeds.
type None struct{}

// Mysql implements Auth interface.
func (n *None) Mysql() mysql.AuthServer {
return new(mysql.AuthServerNone)
}

// Mysql implements Auth interface.
func (n *None) Allowed(user string, permission Permission) error {
return nil
}
14 changes: 13 additions & 1 deletion engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sqle // import "gopkg.in/src-d/go-mysql-server.v0"
import (
opentracing "github.com/opentracing/opentracing-go"
"github.com/sirupsen/logrus"
"gopkg.in/src-d/go-mysql-server.v0/auth"
"gopkg.in/src-d/go-mysql-server.v0/sql"
"gopkg.in/src-d/go-mysql-server.v0/sql/analyzer"
"gopkg.in/src-d/go-mysql-server.v0/sql/expression/function"
Expand All @@ -14,12 +15,15 @@ import (
type Config struct {
// VersionPostfix to display with the `VERSION()` UDF.
VersionPostfix string
// Auth used for authentication and authorization.
Auth auth.Auth
}

// Engine is a SQL engine.
type Engine struct {
Catalog *sql.Catalog
Analyzer *analyzer.Analyzer
Auth auth.Auth
}

// New creates a new Engine with custom configuration. To create an Engine with
Expand All @@ -33,7 +37,15 @@ func New(c *sql.Catalog, a *analyzer.Analyzer, cfg *Config) *Engine {
c.RegisterFunctions(function.Defaults)
c.RegisterFunction("version", sql.FunctionN(function.NewVersion(versionPostfix)))

return &Engine{c, a}
// use auth.None if auth is not specified
var au auth.Auth
if cfg == nil || cfg.Auth == nil {
au = new(auth.None)
} else {
au = cfg.Auth
}

return &Engine{c, a, au}
}

// NewDefault creates a new default Engine.
Expand Down
17 changes: 14 additions & 3 deletions server/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,29 @@ func (s *SessionManager) nextPid() uint64 {
return s.pid
}

// NewSession creates a Session for the given connection.
// newSession creates a Session for the given connection.
func (s *SessionManager) newSession(conn *mysql.Conn) sql.Session {
return s.builder(conn, s.addr)
}

// NewSession creates a Session for the given connection and saves it to
// session pool.
func (s *SessionManager) NewSession(conn *mysql.Conn) {
s.mu.Lock()
s.sessions[conn.ConnectionID] = s.builder(conn, s.addr)
s.sessions[conn.ConnectionID] = s.newSession(conn)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't understand this change. Why do we need s.builder call in another function? It's just one line

s.mu.Unlock()
}

// NewContext creates a new context for the session at the given conn.
func (s *SessionManager) NewContext(conn *mysql.Conn) *sql.Context {
s.mu.Lock()
sess := s.sessions[conn.ConnectionID]
sess, ok := s.sessions[conn.ConnectionID]
if !ok {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case can't happen. If it does not exist in s.sessions, the connection is not opened.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not mentioned that I also had to change when sessions are created. Before the session was created in NewConnection. This is called before the user is authenticated so the sessions did not contain user information (mysql.Conn had User = ""). To overcome this the sessions are created the first time NewContext is called, when he user is already authenticated and we can add the user name to the session.

sess = s.newSession(conn)
s.sessions[conn.ConnectionID] = sess
}
s.mu.Unlock()

context := sql.NewContext(
context.Background(),
sql.WithSession(sess),
Expand Down
1 change: 0 additions & 1 deletion server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ func (h *Handler) NewConnection(c *mysql.Conn) {
}
h.mu.Unlock()

h.sm.NewSession(c)
logrus.Infof("NewConnection: client %v", c.ConnectionID)
}

Expand Down
6 changes: 3 additions & 3 deletions server/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ func TestHandlerKill(t *testing.T) {
conn2 := newConn(2)
handler.NewConnection(conn2)

require.Len(handler.sm.sessions, 2)
require.Len(handler.sm.sessions, 0)
require.Len(handler.c, 2)

err := handler.ComQuery(conn2, "KILL QUERY 1", func(res *sqltypes.Result) error {
Expand All @@ -183,7 +183,7 @@ func TestHandlerKill(t *testing.T) {

require.NoError(err)

require.Len(handler.sm.sessions, 2)
require.Len(handler.sm.sessions, 1)
require.Len(handler.c, 2)
require.Equal(conn1, handler.c[1])
require.Equal(conn2, handler.c[2])
Expand All @@ -193,7 +193,7 @@ func TestHandlerKill(t *testing.T) {
})
require.NoError(err)

require.Len(handler.sm.sessions, 1)
require.Len(handler.sm.sessions, 0)
require.Len(handler.c, 1)
require.Equal(conn1, handler.c[1])
}
6 changes: 4 additions & 2 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

opentracing "github.com/opentracing/opentracing-go"
"gopkg.in/src-d/go-mysql-server.v0"
"gopkg.in/src-d/go-mysql-server.v0/auth"

"gopkg.in/src-d/go-vitess.v1/mysql"
)
Expand All @@ -21,7 +22,7 @@ type Config struct {
// Address of the server.
Address string
// Auth of the server.
Auth mysql.AuthServer
Auth auth.Auth
// Tracer to use in the server. By default, a noop tracer will be used if
// no tracer is provided.
Tracer opentracing.Tracer
Expand Down Expand Up @@ -54,7 +55,8 @@ func NewServer(cfg Config, e *sqle.Engine, sb SessionBuilder) (*Server, error) {
}

handler := NewHandler(e, NewSessionManager(sb, tracer, cfg.Address))
l, err := mysql.NewListener(cfg.Protocol, cfg.Address, cfg.Auth, handler, cfg.ConnReadTimeout, cfg.ConnWriteTimeout)
a := cfg.Auth.Mysql()
l, err := mysql.NewListener(cfg.Protocol, cfg.Address, a, handler, cfg.ConnReadTimeout, cfg.ConnWriteTimeout)
if err != nil {
return nil, err
}
Expand Down
6 changes: 6 additions & 0 deletions sql/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
opentracing "github.com/opentracing/opentracing-go"
"github.com/sirupsen/logrus"
"gopkg.in/src-d/go-errors.v1"
"gopkg.in/src-d/go-mysql-server.v0/auth"
"gopkg.in/src-d/go-mysql-server.v0/sql"
)

Expand Down Expand Up @@ -46,6 +47,11 @@ func (ab *Builder) WithParallelism(parallelism int) *Builder {
return ab
}

// WithAuth adds add authorization rule.
func (ab *Builder) WithAuth(a auth.Auth) *Builder {
return ab.AddPostValidationRule(CheckAuthorizationRule, CheckAuthorization(a))
}

// ReadOnly adds a rule that only allows read queries.
func (ab *Builder) ReadOnly() *Builder {
return ab.AddPreAnalyzeRule(EnsureReadOnlyRule, EnsureReadOnly)
Expand Down
Loading