diff --git a/.gitignore b/.gitignore index 1479ab5..bd070d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ - - logs.log content test-graph @@ -17,7 +15,6 @@ fe/target # Icon must end with two \r Icon - # Thumbnails ._* diff --git a/src/go.mod b/src/go.mod index 3ca784b..2f803f9 100644 --- a/src/go.mod +++ b/src/go.mod @@ -7,13 +7,16 @@ require ( github.com/fatih/color v1.15.0 github.com/go-git/go-git/v5 v5.8.1 github.com/gofrs/uuid v4.2.0+incompatible + github.com/golang-jwt/jwt/v5 v5.0.0 github.com/gomarkdown/markdown v0.0.0-20230922105210-14b16010c2ee github.com/google/go-cmp v0.5.9 github.com/gorilla/mux v1.8.0 + github.com/mitchellh/mapstructure v1.5.0 github.com/pkg/errors v0.9.1 github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 + golang.org/x/crypto v0.12.0 gopkg.in/validator.v2 v2.0.1 gopkg.in/yaml.v2 v2.4.0 ) @@ -62,10 +65,9 @@ require ( github.com/stretchr/testify v1.8.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect go.etcd.io/bbolt v1.3.7 // indirect - golang.org/x/crypto v0.11.0 // indirect golang.org/x/mod v0.8.0 // indirect golang.org/x/net v0.12.0 // indirect - golang.org/x/sys v0.10.0 // indirect + golang.org/x/sys v0.11.0 // indirect golang.org/x/tools v0.6.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/src/go.sum b/src/go.sum index 6a03251..4c28011 100644 --- a/src/go.sum +++ b/src/go.sum @@ -73,6 +73,8 @@ github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+ github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo= github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -114,6 +116,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -153,8 +157,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -185,15 +189,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -201,8 +205,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/src/graph/graph.go b/src/graph/graph.go index 8c8fc9e..73f6b1c 100644 --- a/src/graph/graph.go +++ b/src/graph/graph.go @@ -1,12 +1,14 @@ package graph import ( + "fmt" "regexp" "sort" "strings" "github.com/gofrs/uuid" "github.com/pkg/errors" + "rat/graph/services/auth" "rat/graph/util" pathutil "rat/graph/util/path" ) @@ -23,7 +25,6 @@ var allowedPathNameSymbols = regexp.MustCompile(`[a-zA-Z0-9_\-]`) // Node describes a single node. type Node struct { - // Name string `json:"name"` Path pathutil.NodePath `json:"path"` Header NodeHeader `json:"header"` Content string `json:"content"` @@ -32,6 +33,7 @@ type Node struct { // NodeHeader describes info stored in nodes header. type NodeHeader struct { ID uuid.UUID `yaml:"id"` + Domain auth.Domain `yaml:"domain"` Name string `yaml:"name,omitempty"` Weight int `yaml:"weight,omitempty"` Template *NodeTemplate `yaml:"template,omitempty"` @@ -61,6 +63,21 @@ func (n *Node) Name() string { return n.Path.Name() } +func (n *Node) Domain(p Provider) (auth.Domain, error) { + if n.Header.Domain != "" { + return n.Header.Domain, nil + } + + parent, err := n.Parent(p) + if err != nil { + return "", errors.Wrap(err, "failed to get parent") + } + + fmt.Printf("parent: %v\n", parent.Path) + + return parent.Domain(p) +} + // GetLeafs returns all leafs of node. func (n *Node) GetLeafs(r Reader) ([]*Node, error) { leafs, err := r.GetLeafs(n.Path) diff --git a/src/graph/graph_test.go b/src/graph/graph_test.go index 333ac36..c282077 100644 --- a/src/graph/graph_test.go +++ b/src/graph/graph_test.go @@ -1,6 +1,7 @@ package graph import ( + pathutil "rat/graph/util/path" "testing" "github.com/google/go-cmp/cmp" @@ -56,3 +57,36 @@ func Test_parsePathName(t *testing.T) { }) } } + +func TestNode_Name(t *testing.T) { + t.Parallel() + + type fields struct { + Path pathutil.NodePath + Header NodeHeader + Content string + } + tests := []struct { + name string + fields fields + want string + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + n := &Node{ + Path: tt.fields.Path, + Header: tt.fields.Header, + Content: tt.fields.Content, + } + got := n.Name() + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("Node.Name() = %s", diff) + } + }) + } +} diff --git a/src/graph/services/auth/auth.go b/src/graph/services/auth/auth.go new file mode 100644 index 0000000..dc15b4d --- /dev/null +++ b/src/graph/services/auth/auth.go @@ -0,0 +1,158 @@ +package auth + +import ( + "encoding/json" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/pkg/errors" + "golang.org/x/crypto/bcrypt" + "rat/logr" +) + +type ctxKey string + +const AuthTokenCtxKey ctxKey = "auth-token" + +// Config defines configuration params for authentication. +type Config struct { + Users []*User `yaml:"users"` + Roles map[Role][]*Scope `yaml:"roles"` + Token *TokenConfig `yaml:"token" validate:"nonzero"` +} + +// TokenConfig defines configuration params for JWT token generation. +type TokenConfig struct { + Secret string `yaml:"secret" validate:"nonzero"` + Expiration time.Duration `yaml:"expiration" validate:"nonzero"` +} + +// User defines a user with credentials and role. +type User struct { + Credentials `yaml:",inline"` + Scopes *Scopes `yaml:"scopes" validate:"nonzero"` +} + +// Credentials defines users username and password. +type Credentials struct { + Username string `yaml:"username" validate:"nonzero"` + Password string `yaml:"password" validate:"nonzero"` +} + +type TokenControl struct { + users map[string]*User + roles map[Role][]*Scope + tokenConfig *TokenConfig + log *logr.LogR +} + +func NewTokenControl(config *Config, log *logr.LogR) (*TokenControl, error) { + log = log.Prefix("token-control") + + lg := log.Group(logr.LogLevelInfo) + defer lg.Close() + + lg.Log("users:") + + users := make(map[string]*User) + for _, user := range config.Users { + users[user.Username] = user + + b, err := json.MarshalIndent( + struct { + Username string `json:"username"` + Scopes []*Scope `json:"scopes"` + }{ + Username: user.Username, + Scopes: user.Scopes.Get(config.Roles), + }, + "", + " ", + ) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal user for log") + } + + lg.Log("%s", string(b)) + + } + + return &TokenControl{ + users: users, + roles: config.Roles, + tokenConfig: config.Token, + log: log, + }, nil +} + +func (tc *TokenControl) Generate( + username, password string, +) (string, *Token, error) { + user, ok := tc.users[username] + if !ok { + return "", nil, errors.Errorf("user %q not found", username) + } + + err := bcrypt.CompareHashAndPassword( + []byte(user.Password), + []byte(password), + ) + if err != nil { + return "", nil, errors.Wrap(err, "failed to authenticate user") + } + + token := &Token{ + Username: user.Username, + Expires: time.Now().Add(tc.tokenConfig.Expiration).Unix(), + Scopes: user.Scopes.Get(tc.roles), + } + + signed, err := jwt.NewWithClaims( + jwt.SigningMethodHS512, + token.ToMapClaims(), + ). + SignedString([]byte(tc.tokenConfig.Secret)) + if err != nil { + return "", nil, errors.Wrap(err, "failed to sign token") + } + + return signed, token, nil +} + +func (tc *TokenControl) Validate(signed string) (*Token, error) { + token, err := jwt.Parse( + signed, + func(token *jwt.Token) (any, error) { + return []byte(tc.tokenConfig.Secret), nil + }, + jwt.WithValidMethods([]string{"HS512"}), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to parse token") + } + + if !token.Valid { + return nil, errors.New("token not valid") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("failed to parse claims") + } + + t, err := FromMapClaims(claims) + if err != nil { + return nil, errors.Wrap(err, "failed to parse token from claims") + } + + if t.Expired() { + return nil, errors.New("token expired") + } + + _, ok = tc.users[t.Username] + if !ok { + return nil, errors.New("user not found") + } + + return t, nil +} diff --git a/src/graph/services/auth/scope.go b/src/graph/services/auth/scope.go new file mode 100644 index 0000000..96cac5a --- /dev/null +++ b/src/graph/services/auth/scope.go @@ -0,0 +1,256 @@ +package auth + +import ( + "encoding/json" + "strings" + + "github.com/pkg/errors" + "gopkg.in/yaml.v2" + "rat/graph/util" +) + +// var _ yaml.Unmarshaler = (*Role)(nil) +var ( + _ yaml.Unmarshaler = (*Scopes)(nil) + _ yaml.Unmarshaler = (*Scope)(nil) + _ json.Marshaler = (*Scope)(nil) +) + +// const ( +// // Owner role gives a user unlimited access to the graph. +// Owner Role = "owner" +// // Member role gives a user full access to graph nodes marked with +// // access: member and its sub-nodes. +// Member Role = "member" +// // Viewer role gives a user read access to graph nodes marked with +// // access: viewer and its sub-nodes. +// Viewer Role = "viewer" +// // Visitor role gives read access unauthenticated users to graph nodes +// // marked with access: visitor and its sub-nodes. +// Visitor Role = "visitor" +// ) +// +// const ( +// GraphOwnerNodeRead Scope = "graph_owner_node_read" +// GraphOwnerNodeWrite Scope = "graph_owner_node_write" +// +// GraphMemberNodeRead Scope = "graph_member_node_read" +// GraphMemberNodeWrite Scope = "graph_member_node_write" +// +// GraphVisitorNodeRead Scope = "graph_visitor_node_read" +// GraphVisitorNodeWrite Scope = "graph_visitor_node_write" +// ) +// +// var validScopes = map[Scope]bool{ +// GraphOwnerNodeRead: true, +// GraphOwnerNodeWrite: true, +// GraphMemberNodeRead: true, +// GraphMemberNodeWrite: true, +// GraphVisitorNodeRead: true, +// GraphVisitorNodeWrite: true, +// } + +const ( + GraphNode Resource = "graph_node" +) + +const ( + Read Operation = "read" + Write Operation = "write" +) + +// Resource defines a resource, that belongs to a domain and on which a +// user can perform operations. +type Resource string + +// Domain defines a access domain a resource has. +type Domain string + +// Operation defines a operation a user can perform on a resource. +type Operation string + +// Role defines a user role - an alias for a list of scopes. +type Role string + +// Scope defines a single access scope. A access scope defines a permission +// for a user (that has the particular scope) to perform an operation, on a +// resource in a domain. +// +// Example: +// - `graph_node:owner:read` - defines a scope for the `graph_node` resource +// to perform `read` operation, on nodes that are in `owner` domain. +type Scope struct { + resource Resource + domain Domain + operation Operation +} + +// NewScope creates a new scope. +func NewScope(resource Resource, domain Domain, operation Operation) *Scope { + return &Scope{ + resource: resource, + domain: domain, + operation: operation, + } +} + +func (s *Scope) Satisfied(scopes []*Scope) error { + scopes = util.Filter( + scopes, + func(fs *Scope) bool { + return fs.resource == s.resource || fs.resource == "*" + }, + ) + + scopes = util.Filter( + scopes, + func(fs *Scope) bool { + return fs.domain == s.domain || fs.domain == "*" + }, + ) + + scopes = util.Filter( + scopes, + func(fs *Scope) bool { + return fs.operation == s.operation || fs.operation == "*" + }, + ) + + if len(scopes) == 0 { + return errors.Errorf("no scopes found for %s", s) + } + + return nil +} + +// UnmarshalYAML implements yaml.Unmarshaler for Scope type to check for valid +// values. +func (s *Scope) UnmarshalYAML(unmarshal func(any) error) error { + var raw string + + err := unmarshal(&raw) + if err != nil { + return errors.Wrap(err, "failed to YAML unmarshal scope") + } + + parts := strings.Split(raw, ":") + if len(parts) != 3 { + return errors.Errorf("invalid scope: %s", raw) + } + + s.resource = Resource(parts[0]) + s.domain = Domain(parts[1]) + s.operation = Operation(parts[2]) + + return nil +} + +func (s Scope) String() string { + return strings.Join( + []string{ + string(s.resource), + string(s.domain), + string(s.operation), + }, + ":", + ) +} + +func (s Scope) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} + +func (s *Scope) UnmarshalJSON(b []byte) error { + var raw string + + err := json.Unmarshal(b, &raw) + if err != nil { + return errors.Wrap(err, "failed to JSON unmarshal scope") + } + + parts := strings.Split(raw, ":") + if len(parts) != 3 { + return errors.Errorf("invalid scope: %s", raw) + } + + s.resource = Resource(parts[0]) + s.domain = Domain(parts[1]) + s.operation = Operation(parts[2]) + + return nil +} + +// Scopes defines a list of access scopes or a role. +// Can be unmarshaled from string - a role, defined in the auth configs roles +// map. +// Can be unmarshaled from list of strings - list of scopes. +type Scopes struct { + role Role + scopes []*Scope +} + +// Get returns a list of scopes, either from the role or slice of scopes. +func (s Scopes) Get(roles map[Role][]*Scope) []*Scope { + if len(s.scopes) > 0 { + return s.scopes + } + + scopes, ok := roles[s.role] + if !ok { + return []*Scope{} + } + + return scopes +} + +// UnmarshalYAML implements yaml.Unmarshaler for Scopes to unmarshal either +// string or list of strings. +func (s *Scopes) UnmarshalYAML(unmarshal func(any) error) error { + err := unmarshal(&([]string{})) + if err != nil { + var role Role + + err = unmarshal(&role) + if err != nil { + return errors.Wrap(err, "failed to unmarshal scopes as role") + } + + s.role = role + s.scopes = []*Scope{} + + return nil + } + + scopes := []*Scope{} + + err = unmarshal(&scopes) + if err != nil { + return errors.Wrap(err, "failed to unmarshal scopes as list") + } + + s.scopes = scopes + + return nil +} + +// // UnmarshalYAML implements yaml.Unmarshaler for Role type to check for valid +// // values. +// func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { +// var raw string +// +// err := unmarshal(&raw) +// if err != nil { +// return errors.Wrap(err, "failed to unmarshal role") +// } +// +// role := Role(raw) +// +// switch role { +// case Owner, Member, Viewer: +// *r = role +// +// return nil +// default: +// return errors.Errorf("invalid role: %s", raw) +// } +// } diff --git a/src/graph/services/auth/token.go b/src/graph/services/auth/token.go new file mode 100644 index 0000000..05a8d10 --- /dev/null +++ b/src/graph/services/auth/token.go @@ -0,0 +1,47 @@ +package auth + +import ( + "encoding/json" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/pkg/errors" +) + +// Token defines data stored in JWT token. +type Token struct { + Username string `json:"username"` + Expires int64 `json:"expires"` + Scopes []*Scope `json:"scopes"` +} + +// FromMapClaims converts jwt.MapClaims to token claims. +func FromMapClaims(mc jwt.MapClaims) (*Token, error) { + t := &Token{} + + b, err := json.Marshal(mc) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal token claims") + } + + err = json.Unmarshal(b, t) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal token claims") + } + + return t, nil +} + +// ToMapClaims converts token claims to jwt.MapClaims. +func (t Token) ToMapClaims() jwt.MapClaims { + return jwt.MapClaims{ + "username": t.Username, + "expires": t.Expires, + "scopes": t.Scopes, + } +} + +// Expired returns true if the token is expired. +func (t *Token) Expired() bool { + return time.Unix(t.Expires, 0).Before(time.Now()) +} diff --git a/src/graph/services/provider/access/access.go b/src/graph/services/provider/access/access.go new file mode 100644 index 0000000..c20080b --- /dev/null +++ b/src/graph/services/provider/access/access.go @@ -0,0 +1,235 @@ +package access + +import ( + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "rat/graph" + "rat/graph/services/auth" + pathutil "rat/graph/util/path" + "rat/logr" +) + +var _ graph.Provider = (*Provider)(nil) + +var ErrAccessDenied = errors.New("access denied") + +type Provider struct { + base graph.Provider + log *logr.LogR + scopes []*auth.Scope +} + + + + +// NewProvider creates a new filesystem graph provider. +func NewProvider( + base graph.Provider, + log *logr.LogR, + scopes []*auth.Scope, +) *Provider { + return &Provider{ + base: base, + log: log.Prefix("access"), + scopes: scopes, + } +} + +// GetByID reads node by id, first checkint if role configured for provides +// allows access to node. +func (p *Provider) GetByID(id uuid.UUID) (*graph.Node, error) { + p.log.Debugf("GetByID %s", id.String()) + + n, err := p.base.GetByID(id) + if err != nil { + return nil, errors.Wrap(err, "failed to get node by ID") + } + + domain, err := n.Domain(p.base) + if err != nil { + return nil, errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Read) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return nil, errors.Wrap(err, "scope requirement not satisfied") + } + + return n, nil +} + +func (p *Provider) GetByPath(path pathutil.NodePath) (*graph.Node, error) { + p.log.Debugf("GetByPath %s", path.String()) + + n, err := p.base.GetByPath(path) + if err != nil { + return nil, errors.Wrap(err, "failed to get node by path") + } + + domain, err := n.Domain(p.base) + if err != nil { + return nil, errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Read) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return nil, errors.Wrap(err, "scope requirement not satisfied") + } + + return n, nil +} + +func (p *Provider) GetLeafs(path pathutil.NodePath) ([]*graph.Node, error) { + p.log.Debugf("GetLeafs %s", path.String()) + + // check access to parent node + _, err := p.GetByPath(path) + if err != nil { + return nil, errors.Wrap(err, "failed to get node by path") + } + + p.log.Debugf("base.GetLeafs %s", path.String()) + + leafs, err := p.base.GetLeafs(path) + if err != nil { + return nil, errors.Wrap(err, "failed to get node by path") + } + + p.log.Debugf( + "iterate leafs for node %q, len(%d)", path.String(), len(leafs), + ) + + var allowedLeafs []*graph.Node + + for _, leaf := range leafs { + domain, err := leaf.Domain(p.base) + if err != nil { + return nil, errors.Wrap(err, "failed to get node domain") + } + + p.log.Debugf("got domain %q for leaf %q", domain, leaf.Path) + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Read) + + p.log.Debugf("leaf %q requires scope %q", leaf.Path, requiredScope) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + p.log.Debugf("leaf %q not allowed: %s", leaf.Path, err.Error()) + + continue + } + + p.log.Debugf("leaf %q allowed", leaf.Path) + + allowedLeafs = append(allowedLeafs, leaf) + } + + return allowedLeafs, nil +} + +func (p *Provider) Move(id uuid.UUID, path pathutil.NodePath) error { + p.log.Debugf("Move %s %s", id.String(), path.String()) + + n, err := p.base.GetByID(id) + if err != nil { + return errors.Wrap(err, "failed to get node by ID") + } + + domain, err := n.Domain(p.base) + if err != nil { + return errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Write) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return errors.Wrap(err, "scope requirement not satisfied") + } + + n, err = p.GetByPath(path.Parent()) + + domain, err = n.Domain(p.base) + if err != nil { + return errors.Wrap(err, "failed to get node domain") + } + + requiredScope = auth.NewScope(auth.GraphNode, domain, auth.Write) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return errors.Wrap(err, "scope requirement not satisfied") + } + + err = p.base.Move(id, path) + if err != nil { + return errors.Wrap(err, "failed to move node") + } + + return nil +} + +// TODO: write is accting like a create and update at the same time. this is +// not ideal and the interface should be separated into two methods. +func (p *Provider) Write(n *graph.Node) error { + p.log.Debugf("Write %s", n.Path.String()) + + // curretly access is only checked for a create scenario, for which write + // has the only use case. this remains sub-optimal and should be fixes. + parent, err := p.base.GetByPath(n.Path.Parent()) + if err != nil { + return errors.Wrap(err, "failed to get parent node") + } + + domain, err := parent.Domain(p.base) + if err != nil { + return errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Write) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return errors.Wrap(err, "scope requirement not satisfied") + } + + err = p.base.Write(n) + if err != nil { + return errors.Wrap(err, "failed to write node") + } + + return nil +} + +func (p *Provider) Delete(n *graph.Node) error { + p.log.Debugf("Delete %s", n.Path.String()) + + existing, err := p.base.GetByPath(n.Path) + if err != nil { + return errors.Wrap(err, "failed to get node by path") + } + + domain, err := existing.Domain(p.base) + if err != nil { + return errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Write) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return errors.Wrap(err, "scope requirement not satisfied") + } + + err = p.base.Delete(n) + if err != nil { + return errors.Wrap(err, "failed to delete node") + } + + return nil +} diff --git a/src/graph/provider/filesystem/filesystem.go b/src/graph/services/provider/filesystem/filesystem.go similarity index 100% rename from src/graph/provider/filesystem/filesystem.go rename to src/graph/services/provider/filesystem/filesystem.go diff --git a/src/graph/provider/pathcache/pathcache.go b/src/graph/services/provider/pathcache/pathcache.go similarity index 96% rename from src/graph/provider/pathcache/pathcache.go rename to src/graph/services/provider/pathcache/pathcache.go index 80dde24..67fe557 100644 --- a/src/graph/provider/pathcache/pathcache.go +++ b/src/graph/services/provider/pathcache/pathcache.go @@ -20,8 +20,8 @@ type Provider struct { cacheMu sync.Mutex } -// NewPathCache returns a new PathCache. -func NewPathCache(base graph.Provider, log *logr.LogR) *Provider { +// NewProvider returns a new PathCache. +func NewProvider(base graph.Provider, log *logr.LogR) *Provider { log = log.Prefix("pathcache") log.Infof("enabled") diff --git a/src/graph/provider/provider.go b/src/graph/services/provider/provider.go similarity index 82% rename from src/graph/provider/provider.go rename to src/graph/services/provider/provider.go index cb7932c..7ebd267 100644 --- a/src/graph/provider/provider.go +++ b/src/graph/services/provider/provider.go @@ -3,9 +3,9 @@ package provider import ( "github.com/pkg/errors" "rat/graph" - "rat/graph/provider/filesystem" - "rat/graph/provider/pathcache" - "rat/graph/provider/root" + "rat/graph/services/provider/filesystem" + "rat/graph/services/provider/pathcache" + "rat/graph/services/provider/root" "rat/logr" ) @@ -31,7 +31,7 @@ func New( //nolint:ireturn // i know better. var p graph.Provider = root.NewProvider(fs, c.Root) if c.EnablePathCache == nil || *c.EnablePathCache { - p = pathcache.NewPathCache(p, log) + p = pathcache.NewProvider(p, log) } return p, nil diff --git a/src/graph/provider/root/root.go b/src/graph/services/provider/root/root.go similarity index 95% rename from src/graph/provider/root/root.go rename to src/graph/services/provider/root/root.go index 3c0f7a0..7009bc4 100644 --- a/src/graph/provider/root/root.go +++ b/src/graph/services/provider/root/root.go @@ -4,6 +4,7 @@ import ( "github.com/gofrs/uuid" "github.com/pkg/errors" "rat/graph" + "rat/graph/services/auth" pathutil "rat/graph/util/path" ) @@ -20,6 +21,7 @@ var defaultConfig = Config{ //nolint:gochecknoglobals // constant. // Config contains root node provider configuration parameters. type Config struct { + Domain auth.Domain `yaml:"domain"` Content string `yaml:"content"` Template *graph.NodeTemplate `yaml:"template"` } @@ -108,6 +110,7 @@ func (p *Provider) rootNode() *graph.Node { return &graph.Node{ Header: graph.NodeHeader{ ID: uuid.Nil, + Domain: p.root.Domain, Template: p.root.Template, }, Content: p.root.Content, @@ -121,6 +124,10 @@ func (c *Config) fillDefaults() Config { fill := *c + if c.Domain == "" { + fill.Domain = defaultConfig.Domain + } + if c.Content == "" { fill.Content = defaultConfig.Content } diff --git a/src/graph/provider/root/root_test.go b/src/graph/services/provider/root/root_test.go similarity index 100% rename from src/graph/provider/root/root_test.go rename to src/graph/services/provider/root/root_test.go diff --git a/src/graph/services/services.go b/src/graph/services/services.go index eb8280c..4320c8d 100644 --- a/src/graph/services/services.go +++ b/src/graph/services/services.go @@ -6,9 +6,11 @@ import ( "github.com/pkg/errors" "rat/graph" "rat/graph/index" - "rat/graph/provider" + "rat/graph/services/auth" + "rat/graph/services/provider" "rat/graph/services/urlresolve" "rat/graph/sync" + "rat/handler/api" "rat/logr" ) @@ -17,6 +19,8 @@ type Config struct { Provider *provider.Config `yaml:"provider"` URLResolver *urlresolve.Config `yaml:"urlResolver"` Sync *sync.Config `yaml:"sync"` + Auth *auth.Config `yaml:"auth"` + API *api.Config `yaml:"api"` } // GraphServices contains service components of a graph. @@ -25,6 +29,8 @@ type GraphServices struct { Syncer *sync.Syncer Index *index.GraphIndex URLResolver *urlresolve.Resolver + Auth *auth.TokenControl + API *api.API log *logr.LogR } @@ -40,6 +46,13 @@ func NewGraphServices(c *Config, log *logr.LogR) (*GraphServices, error) { } ) + if c.Auth != nil { + gs.Auth, err = auth.NewTokenControl(c.Auth, log) + if err != nil { + return nil, errors.Wrap(err, "failed to create token control") + } + } + gs.Provider, err = provider.New(c.Provider, log) if err != nil { return nil, errors.Wrap(err, "failed to create graph provider") @@ -61,6 +74,11 @@ func NewGraphServices(c *Config, log *logr.LogR) (*GraphServices, error) { return nil, errors.Wrap(err, "failed to create index") } + gs.API, err = api.NewAPI(c.API, log) + if err != nil { + return nil, errors.Wrap(err, "failed to create api") + } + logMetrics(gs.Provider, log.Prefix("metrics")) return gs, nil diff --git a/src/graph/util/path/path.go b/src/graph/util/path/path.go index db46901..570e2b1 100644 --- a/src/graph/util/path/path.go +++ b/src/graph/util/path/path.go @@ -21,7 +21,7 @@ func (p NodePath) Parent() NodePath { parts := p.Parts() if len(parts) < 2 { - return p + return "" } return NodePath(strings.Join(parts[:len(parts)-1], "/")) diff --git a/src/graph/util/util.go b/src/graph/util/util.go index 3dae55e..82ebf56 100644 --- a/src/graph/util/util.go +++ b/src/graph/util/util.go @@ -23,6 +23,7 @@ func Map[T, R any](s []T, f func(T) R) []R { // Values returns all values of a map. func Values[K comparable, V any](m map[K]V) []V { r := make([]V, 0, len(m)) + for _, v := range m { r = append(r, v) } @@ -30,6 +31,17 @@ func Values[K comparable, V any](m map[K]V) []V { return r } +// ObjectMap returns a map of objects by the given key. +func ObjectMap[K comparable, V any](objects []V, key func(V) K) map[K]V { + r := make(map[K]V, len(objects)) + + for _, o := range objects { + r[key(o)] = o + } + + return r +} + // Filter creates a new slice of entries of s that valid function return's // true to. func Filter[T any](s []T, valid func(T) bool) []T { diff --git a/src/handler/api/api.go b/src/handler/api/api.go new file mode 100644 index 0000000..1c7b754 --- /dev/null +++ b/src/handler/api/api.go @@ -0,0 +1,94 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "github.com/pkg/errors" + "rat/handler/api/router" + "rat/logr" +) + +var DefaultTimeouts = &Timeouts{ + Read: 15 * time.Second, + Write: 15 * time.Second, +} + +type Config struct { + Port int `yaml:"port" validate:"nonzero"` + Timeouts *Timeouts `yaml:"timeouts"` +} + +type Timeouts struct { + Read time.Duration `yaml:"read"` + Write time.Duration `yaml:"write"` +} + +type API struct { + log *logr.LogR + auth bool + config *Config + server *http.Server +} + +func NewAPI(config *Config, log *logr.LogR) (*API, error) { + log = log.Prefix("api") + + r, err := router.NewRouter(log, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create router") + } + + r.Path("/graph") + + timeouts := config.Timeouts.FillDefaults() + + return &API{ + log: log, + config: config, + + server: &http.Server{ + Handler: r, + Addr: fmt.Sprintf(":%d", config.Port), + WriteTimeout: timeouts.Write, + ReadTimeout: timeouts.Read, + }, + }, nil +} + +// Serve starts the rat server. Blocks. +func (api *API) Serve(exit chan error) { + api.log.Infof("serving on http://localhost:%d", api.config.Port) + + start := time.Now() + + err := api.server.ListenAndServe() + if err != nil { + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + } + + r.log.Infof("uptime: %s", time.Since(start).String()) + + exit <- errors.Wrap(err, "listen and serve error") +} + +func (t *Timeouts) FillDefaults() *Timeouts { + if t == nil { + return DefaultTimeouts + } + + fill := *t + + if fill.Read == 0 { + fill.Read = DefaultTimeouts.Read + } + + if fill.Write == 0 { + fill.Write = DefaultTimeouts.Write + } + + return &fill +} diff --git a/src/handler/api/router/router.go b/src/handler/api/router/router.go new file mode 100644 index 0000000..bc61aba --- /dev/null +++ b/src/handler/api/router/router.go @@ -0,0 +1,103 @@ +package router + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "rat/graph" + "rat/graph/services/auth" + "rat/graph/services/provider/access" + "rat/handler/httputil" + "rat/logr" +) + +// GraphHandlerFunc defines a handler function signature for HTTP request +// that request handler functions that interact with the graph. +type GraphHandlerFunc func( + p graph.Provider, w http.ResponseWriter, r *http.Request, +) error + +type Router struct { + log *logr.LogR + provider graph.Provider + muxRouter *mux.Router +} + +type Handler struct { + router *Router + method string +} + +func NewRouter(log *logr.LogR, provider graph.Provider) (*Router, error) { + log = log.Prefix("router") + + // router := mux.NewRouter() + + return &Router{ + log: log, + provider: provider, + muxRouter: mux.NewRouter(), + }, nil +} + +func (r *Router) Path(path string) *Router { + newMuxRouter := r.muxRouter.PathPrefix(path).Subrouter() + + return &Router{ + muxRouter: newMuxRouter, + } +} + +func (r *Router) Handler(method string) *Handler { + return &Handler{ + router: r, + method: method, + } +} + +func (h *Handler) GraphHandler( + handler GraphHandlerFunc, +) { + r.router.HandleFunc(path, r.graphAccessWrapper(r.provider, r.log, handler)) +} + +var _ http.Handler = (*Router)(nil) + +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.muxRouter.ServeHTTP(w, req) +} + +func (r *Router) graphAccessWrapper( + base graph.Provider, + log *logr.LogR, + handler GraphHandlerFunc, +) httputil.RatHandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + token, ok := r.Context().Value(auth.AuthTokenCtxKey).(*auth.Token) + if !ok || token == nil { + return httputil.Error( + http.StatusInternalServerError, + errors.New("failed to get auth token from context"), + ) + } + + b, err := json.MarshalIndent(token, "", " ") + if err != nil { + return httputil.Error( + http.StatusInternalServerError, + errors.Wrap(err, "failed to marshal token"), + ) + } + + log.Debugf("token:\n%s", string(b)) + + err = handler(access.NewProvider(base, log, token.Scopes), w, r) + if err != nil { + return err + } + + return nil + } +} diff --git a/src/handler/authhttp/authhttp.go b/src/handler/authhttp/authhttp.go new file mode 100644 index 0000000..2afc364 --- /dev/null +++ b/src/handler/authhttp/authhttp.go @@ -0,0 +1,142 @@ +package auth + +import ( + "context" + "net/http" + "strings" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "rat/graph/services" + "rat/graph/services/auth" + "rat/handler/httputil" + "rat/logr" +) + +type handler struct { + log *logr.LogR + gs *services.GraphServices +} + +// RegisterRoutes registers auth routes on given router. +func RegisterRoutes( + router *mux.Router, log *logr.LogR, gs *services.GraphServices, +) (mux.MiddlewareFunc, error) { + log = log.Prefix("auth") + + h := &handler{ + log: log, + gs: gs, + } + + authRouter := router.PathPrefix("/auth").Subrouter() + + authRouter.HandleFunc( + "", + httputil.Wrap( + httputil.WrapOptions( + h.auth, + []string{http.MethodPost}, + []string{"Content-Type"}, + ), + log, + "auth", + ), + ).Methods(http.MethodPost, http.MethodOptions) + + return h.authMW, nil +} + +func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { + body, err := httputil.Body[struct { + Username string `json:"username"` + Password string `json:"password"` + }](w, r) + if err != nil { + return httputil.Error( + http.StatusBadRequest, errors.Wrap(err, "failed to parse body"), + ) + } + + signed, _, err := h.gs.Auth.Generate(body.Username, body.Password) + if err != nil { + return httputil.Error( + http.StatusBadRequest, errors.Wrap(err, "failed to generate token"), + ) + } + + err = httputil.WriteResponse( + w, + http.StatusOK, + struct { + Token string `json:"token"` + }{ + Token: signed, + }, + ) + if err != nil { + return httputil.Error( + http.StatusBadRequest, + errors.Wrap(err, "failed to write response"), + ) + } + + return nil +} + +func (h *handler) authMW(next http.Handler) http.Handler { + return http.HandlerFunc(httputil.Wrap( + func(w http.ResponseWriter, r *http.Request) error { + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + + return nil + } + + signed, err := getToken(r) + if err != nil { + return httputil.Error( + http.StatusBadRequest, + errors.Wrap(err, "failed to get token"), + ) + } + + token, err := h.gs.Auth.Validate(signed) + if err != nil { + return httputil.Error( + http.StatusUnauthorized, + errors.Wrap(err, "failed to validate token"), + ) + } + + r = r.WithContext( + context.WithValue(r.Context(), auth.AuthTokenCtxKey, token), + ) + + next.ServeHTTP(w, r) + + return nil + }, + h.log, + "auth-middleware", + )) +} + +func getToken(r *http.Request) (string, error) { + headerParts := strings.Fields(r.Header.Get("Authorization")) + + if len(headerParts) != 2 { + return "", errors.New( + `invalid Authorization header, expected 2 parts - "Bearer "`, + ) + } + + if !strings.EqualFold("Bearer", headerParts[0]) { + return "", errors.Errorf( + `invalid Authorization token kind - %q, expected "Bearer"`, + headerParts[0], + ) + } + + return headerParts[1], nil +} diff --git a/src/handler/fileshttp/fileshttp.go b/src/handler/fileshttp/fileshttp.go index f34f988..e6afba1 100644 --- a/src/handler/fileshttp/fileshttp.go +++ b/src/handler/fileshttp/fileshttp.go @@ -13,6 +13,9 @@ import ( "rat/logr" ) +// requires graph +// + type handler struct { log *logr.LogR resolver *urlresolve.Resolver diff --git a/src/handler/graphhttp/nodeshttp/nodeshttp.go b/src/handler/graphhttp/nodeshttp/nodeshttp.go index 8349a45..f1327a5 100644 --- a/src/handler/graphhttp/nodeshttp/nodeshttp.go +++ b/src/handler/graphhttp/nodeshttp/nodeshttp.go @@ -1,6 +1,7 @@ package nodeshttp import ( + "encoding/json" "net/http" "strings" @@ -11,6 +12,8 @@ import ( "rat/graph/render" "rat/graph/render/jsonast" "rat/graph/services" + "rat/graph/services/auth" + "rat/graph/services/provider/access" pathutil "rat/graph/util/path" "rat/handler/httputil" "rat/logr" @@ -31,6 +34,51 @@ type response struct { ChildNodes []*response `json:"childNodes,omitempty"` } + + +// GraphHandlerFunc defines a handler function for requests that interact with +// the graph. +type GraphHandlerFunc func( + p graph.Provider, w http.ResponseWriter, r *http.Request, +) error + +func (ghf GraphHandlerFunc) Handler() http.HandlerFunc { + return nil +} + +func accessWrapper( + base graph.Provider, + log *logr.LogR, + handler GraphHandlerFunc, +) httputil.RatHandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + token, ok := r.Context().Value(auth.AuthTokenCtxKey).(*auth.Token) + if !ok || token == nil { + return httputil.Error( + http.StatusInternalServerError, + errors.New("failed to get auth token from context"), + ) + } + + b, err := json.MarshalIndent(token, "", " ") + if err != nil { + return httputil.Error( + http.StatusInternalServerError, + errors.Wrap(err, "failed to marshal token"), + ) + } + + log.Debugf("token:\n%s", string(b)) + + err = handler(access.NewProvider(base, log, token.Scopes), w, r) + if err != nil { + return err + } + + return nil + } +} + // RegisterRoutes registers graph routes on given router. func RegisterRoutes( router *mux.Router, log *logr.LogR, gs *services.GraphServices, @@ -53,13 +101,19 @@ func RegisterRoutes( return nil }, []string{http.MethodGet, http.MethodPost, http.MethodDelete}, - []string{"Content-Type"}, + []string{"Content-Type", "Authorization"}, ), - log, "read"), + log, + "read", + ), ).Methods(http.MethodOptions) - nodeRouter.HandleFunc("", httputil.Wrap(h.read, log, "read")). - Methods(http.MethodGet) + nodeRouter.HandleFunc( + "", httputil.Wrap(accessWrapper(gs.Provider, log, h.read), log, "read"), + ).Methods(http.MethodGet) + + // nodeRouter.HandleFunc("", GraphHandlerFunc(h.read).Handler()). + // Methods(http.MethodGet) nodeRouter.HandleFunc("", httputil.Wrap(h.create, h.log, "create")). Methods(http.MethodPost) @@ -70,8 +124,10 @@ func RegisterRoutes( return nil } -func (h *handler) read(w http.ResponseWriter, r *http.Request) error { - n, err := h.getNode(r) +func (h *handler) read( + p graph.Provider, w http.ResponseWriter, r *http.Request, +) error { + n, err := getNode(p, r) if err != nil { return httputil.Error( http.StatusInternalServerError, @@ -81,13 +137,17 @@ func (h *handler) read(w http.ResponseWriter, r *http.Request) error { root := jsonast.NewRootAstPart("document") - h.r.Render(root, n, n.Content) + render.NewJSONRenderer(h.log, p).Render(root, n, n.Content) + + h.log.Debugf("rendered node %q", n.Path) - childNodes, err := h.getChildNodes(w, n) + childNodes, err := getChildNodes(p, h.log, w, n) if err != nil { return errors.Wrap(err, "failed to get child node paths") } + h.log.Debugf("got child nodes for node %q", n.Path) + err = httputil.WriteResponse( w, http.StatusOK, @@ -115,7 +175,7 @@ func (h *handler) create(w http.ResponseWriter, r *http.Request) error { return errors.Wrap(err, "failed to get body") } - n, err := h.getNode(r) + n, err := getNode(h.gs.Provider, r) if err != nil { return httputil.Error( http.StatusInternalServerError, @@ -154,7 +214,7 @@ func (h *handler) create(w http.ResponseWriter, r *http.Request) error { } func (h *handler) delete(w http.ResponseWriter, r *http.Request) error { - n, err := h.getNode(r) + n, err := getNode(h.gs.Provider, r) if err != nil { return httputil.Error( http.StatusInternalServerError, @@ -176,11 +236,13 @@ func (h *handler) delete(w http.ResponseWriter, r *http.Request) error { return nil } -func (h *handler) getChildNodes( +func getChildNodes( + p graph.Provider, + log *logr.LogR, w http.ResponseWriter, n *graph.Node, ) ([]*response, error) { - children, err := n.GetLeafs(h.gs.Provider) + children, err := n.GetLeafs(p) if err != nil { httputil.WriteError( w, @@ -196,7 +258,7 @@ func (h *handler) getChildNodes( for _, child := range children { root := jsonast.NewRootAstPart("document") - h.r.Render(root, child, child.Content) + render.NewJSONRenderer(log, p).Render(root, child, child.Content) childNodes = append( childNodes, @@ -213,10 +275,10 @@ func (h *handler) getChildNodes( return childNodes, nil } -func (h *handler) getNode(r *http.Request) (*graph.Node, error) { +func getNode(p graph.Provider, r *http.Request) (*graph.Node, error) { path := mux.Vars(r)["path"] - n, err := h.gs.Provider.GetByPath(pathutil.NodePath(path)) + n, err := p.GetByPath(pathutil.NodePath(path)) if err != nil { return nil, errors.Wrapf(err, "failed to get node %q", path) } diff --git a/src/handler/router/router.go b/src/handler/router/router.go index c375f1f..0dc35cd 100644 --- a/src/handler/router/router.go +++ b/src/handler/router/router.go @@ -8,6 +8,7 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" "rat/graph/services" + auth "rat/handler/authhttp" "rat/handler/graphhttp" "rat/handler/httputil" "rat/handler/viewhttp" @@ -54,19 +55,39 @@ func NewRouter( router.Use(GetAccessLoggerMW(log, false)) - err := graphhttp.RegisterRoutes(router, log, gs) + exposedRouter := router.PathPrefix("").Subrouter() + protectedRouter := router.PathPrefix("").Subrouter() + + if gs.Auth != nil { + log.Infof("registering auth routes") + + mw, err := auth.RegisterRoutes(exposedRouter, log, gs) + if err != nil { + return nil, errors.Wrap(err, "failed to register auth routes") + } + + protectedRouter.Use(mw) + } + + err := graphhttp.RegisterRoutes(protectedRouter, log, gs) if err != nil { return nil, errors.Wrap(err, "failed to register graph routes") } - err = viewhttp.RegisterRoutes(router, log, webStaticContent) + err = viewhttp.RegisterRoutes(exposedRouter, log, webStaticContent) if err != nil { return nil, errors.Wrap(err, "failed to register web routes") } - router.HandleFunc("/test", + protectedRouter.HandleFunc("/test", func(w http.ResponseWriter, _ *http.Request) { - httputil.WriteError(w, http.StatusOK, "not found") + httputil.WriteResponse(w, http.StatusOK, + struct { + Message string `json:"message"` + }{ + Message: "r we testing, huh?", + }, + ) }, ) diff --git a/src/note.md b/src/note.md new file mode 100644 index 0000000..1303811 --- /dev/null +++ b/src/note.md @@ -0,0 +1,11 @@ + + + +- with auth + - with graph + - graphhttp + - nodehttp + - fileshttp +- without auth + - auth + - view (separate server) diff --git a/src/scripts/gen-paswd-hash/main.go b/src/scripts/gen-paswd-hash/main.go new file mode 100644 index 0000000..fbed532 --- /dev/null +++ b/src/scripts/gen-paswd-hash/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" + "rat/logr" +) + +func main() { + var ( + password string + cost int + log = logr.NewLogR( + os.Stdout, + "rat-gen-paswd-hash", + logr.LogLevelDebug, + ) + ) + + cli := &cobra.Command{ + Use: "gen-paswd-hash", + Short: "generate a password hash", + Long: "generate a password hash to be used for graph owners auth", + Run: func(cmd *cobra.Command, args []string) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + panic(err) + } + + log.Infof("password hash:\n%s", string(hash)) + }, + } + + cli.Flags(). + StringVarP(&password, "password", "p", "", "password to hash") + + err := cli.MarkFlagRequired("password") + if err != nil { + panic(err) + } + + cli.Flags().IntVarP( + &cost, "cost", "c", bcrypt.MinCost, "cost of bcrypt hash function", + ) + + err = cli.Execute() + if err != nil { + panic(err) + } +} diff --git a/src/web/src/api/auth.ts b/src/web/src/api/auth.ts new file mode 100644 index 0000000..2336706 --- /dev/null +++ b/src/web/src/api/auth.ts @@ -0,0 +1,11 @@ +import { ratAPIBaseURL } from "./api"; +import axios from "axios"; + +export async function signIn(username: string, password: string) { + let resp = await axios.post<{ token: string }>(`${ratAPIBaseURL()}/auth`, { + username, + password, + }); + + return resp.data.token; +} diff --git a/src/web/src/api/node.ts b/src/web/src/api/node.ts index 66ad4a9..4768e79 100644 --- a/src/web/src/api/node.ts +++ b/src/web/src/api/node.ts @@ -3,15 +3,19 @@ import { Node } from "../types/node"; import axios from "axios"; export async function create(path: string, name: string) { - const url = `${ratAPIBaseURL()}/graph/node/${path}`; - - let resp = await axios.post(url, { name }); + let resp = await axios.post(`${ratAPIBaseURL()}/graph/node/${path}`, { + name, + }); return resp.data; } -export async function read(path: string) { - let resp = await axios.get(`${ratAPIBaseURL()}/graph/node/${path}`); +export async function read(token: string, path: string) { + let resp = await axios.get(`${ratAPIBaseURL()}/graph/node/${path}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); return resp.data; } diff --git a/src/web/src/components/atoms.tsx b/src/web/src/components/atoms.tsx index db9d0df..7318785 100644 --- a/src/web/src/components/atoms.tsx +++ b/src/web/src/components/atoms.tsx @@ -1,5 +1,22 @@ import { atom } from "jotai"; import { Node, NodeAstPart } from "../types/node"; +import { Session } from "../types/session"; + +export const sessionAtom = atom( + (() => { + let localAPIToken = localStorage.getItem("rat_api_token"); + + if (!localAPIToken || localAPIToken === "") { + return; + } + + //TODO: parse jwt, check expiration, populate other fields. + + return { + token: localAPIToken, + }; + })(), +); export const nodeAtom = atom(undefined); diff --git a/src/web/src/components/buttons/buttons.module.css b/src/web/src/components/buttons/buttons.module.css index 90aecdb..5ae4e31 100644 --- a/src/web/src/components/buttons/buttons.module.css +++ b/src/web/src/components/buttons/buttons.module.css @@ -38,42 +38,3 @@ flex-wrap: wrap; gap: 6px; } - -.clickable { - cursor: pointer; -} - -.clickable:hover { - border: 3px solid #841a84; -} - -.clickable:active { - border: 3px solid #ffffff; -} - -/* .consoleButton { */ -/* overflow-wrap: anywhere; */ -/* display: inline-block; */ -/* border: 3px solid #474646; */ -/* background-color: #474646; */ -/* padding-left: 5px; */ -/* padding-right: 5px; */ -/* border-radius: 5px; */ -/* font-size: 130%; */ -/* margin-right: 6px; */ -/* margin-bottom: 6px; */ -/* box-sizing: border-box; */ -/* } */ - -/* .deleteButton { */ -/* position: absolute; */ -/* z-index: 1; */ -/* width: 40px; */ -/* height: 40px; */ -/* background-color: #474646; */ -/* top: 30px; */ -/* right: 30px; */ -/* border-radius: 5px; */ -/* border: 3px solid #474646; */ -/* padding: 2px; */ -/* } */ diff --git a/src/web/src/components/console.tsx b/src/web/src/components/console.tsx index 994d113..0ca0cde 100644 --- a/src/web/src/components/console.tsx +++ b/src/web/src/components/console.tsx @@ -1,4 +1,4 @@ -import { modalOpenAtom, nodePathAtom, childNodesAtom } from "./atoms"; +import { nodeAtom, modalOpenAtom, nodePathAtom, childNodesAtom } from "./atoms"; import { TextButton, IconButton, ButtonRow } from "./buttons/buttons"; import { ConfirmModal, ContentModal } from "./modals"; import { Spacer } from "./util"; @@ -17,15 +17,20 @@ import { useNavigate } from "react-router-dom"; import { useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -export function Console({ id }: { id: string }) { +export function Console() { const navigate = useNavigate(); - const nodePath = useAtomValue(nodePathAtom); - const isRoot = nodePath === ""; + const node = useAtomValue(nodeAtom); + + if (!node) { + return <>; + } + + const isRoot = node.path === ""; let pathParts: string[] = []; - if (nodePath) { - pathParts = nodePath.split("/"); + if (node.path) { + pathParts = node.path.split("/"); } return ( @@ -33,17 +38,17 @@ export function Console({ id }: { id: string }) { {!isRoot && ( { - navigator.clipboard.writeText(id); + navigator.clipboard.writeText(node.id); }} /> { - navigator.clipboard.writeText(nodePath); + navigator.clipboard.writeText(node.path); }} /> diff --git a/src/web/src/components/parts.module.css b/src/web/src/components/parts.module.css index bfff381..79961e9 100644 --- a/src/web/src/components/parts.module.css +++ b/src/web/src/components/parts.module.css @@ -40,18 +40,6 @@ white-space: nowrap; } -.link { - overflow-wrap: anywhere; -} - -.link:link { - color: #f3715d; -} - -.link:visited { - color: #f3715d; -} - .paragraph { display: block; margin-block-start: 1em; @@ -113,18 +101,6 @@ border-radius: 8px; } -.container { - position: relative; - - padding-top: 0; - padding-bottom: 0; - padding-left: 30px; - padding-right: 30px; - background-color: #111111; - border: 3px solid #111111; - border-radius: 8px; -} - .graphviz { justify-content: center; display: flex; diff --git a/src/web/src/components/parts.tsx b/src/web/src/components/parts.tsx index 4b4d2ff..734ad42 100644 --- a/src/web/src/components/parts.tsx +++ b/src/web/src/components/parts.tsx @@ -2,7 +2,13 @@ import React from "react"; import { Node, NodeAstPart } from "../types/node"; import { nodeAstAtom, childNodesAtom } from "./atoms"; -import { Spacer } from "./util"; +import { + ClickableContainer, + Container, + ExternalLink, + InternalLink, + Spacer, +} from "./util"; import { move } from "../api/graph"; import styles from "./parts.module.css"; @@ -12,7 +18,7 @@ import { darcula as SyntaxHighlighterStyle } from "react-syntax-highlighter/dist import { useState, useEffect, useMemo } from "react"; import { useAtomValue } from "jotai"; import { graphviz } from "d3-graphviz"; -import { useNavigate, Link as RouterLink } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useDroppable, useDraggable, @@ -164,9 +170,17 @@ export function NodePart({ part }: { part: NodeAstPart }) { case "code_block": return ; case "link": - return ; + return ( + + + + ); case "graph_link": - return ; + return ( + + + + ); case "list": return ; case "list_item": @@ -364,26 +378,6 @@ function CodeBlock({ part }: { part: NodeAstPart }) { ); } -function Link({ part }: { part: NodeAstPart }) { - const href = part.attributes["destination"] as string; - - return ( - - - - ); -} - -function GraphLink({ part }: { part: NodeAstPart }) { - const href = part.attributes["destination"] as string; - - return ( - - - - ); -} - function List({ part }: { part: NodeAstPart }) { // {(part.attributes["ordered"] as boolean) &&

ordered

} // {(part.attributes["definition"] as boolean) &&

definition

} @@ -664,20 +658,3 @@ function NodePartChildren({ part }: { part: NodeAstPart }) { ); } - -function ClickableContainer( - props: React.PropsWithChildren<{ onClick?: () => void }>, -) { - return ( -
- {props.children} -
- ); -} - -function Container(props: React.PropsWithChildren<{}>) { - return
{props.children}
; -} diff --git a/src/web/src/components/util.module.css b/src/web/src/components/util.module.css new file mode 100644 index 0000000..c099222 --- /dev/null +++ b/src/web/src/components/util.module.css @@ -0,0 +1,35 @@ +.link { + overflow-wrap: anywhere; +} + +.link:link { + color: #f3715d; +} + +.link:visited { + color: #f3715d; +} + +.container { + position: relative; + + padding-top: 0; + padding-bottom: 0; + padding-left: 30px; + padding-right: 30px; + background-color: #111111; + border: 3px solid #111111; + border-radius: 8px; +} + +.clickable { + cursor: pointer; +} + +.clickable:hover { + border: 3px solid #841a84; +} + +.clickable:active { + border: 3px solid #ffffff; +} diff --git a/src/web/src/components/util.tsx b/src/web/src/components/util.tsx index af8eb82..71a495d 100644 --- a/src/web/src/components/util.tsx +++ b/src/web/src/components/util.tsx @@ -1,3 +1,7 @@ +import styles from "./util.module.css"; + +import { Link as RouterLink } from "react-router-dom"; + export function Spacer({ width = 0, height = 0, @@ -21,3 +25,36 @@ export function Spacer({ return
; } + +export function InternalLink(props: React.PropsWithChildren<{ href: string }>) { + return ( + + {props.children} + + ); +} + +export function ExternalLink(props: React.PropsWithChildren<{ href: string }>) { + return ( + + {props.children} + + ); +} + +export function Container(props: React.PropsWithChildren<{}>) { + return
{props.children}
; +} + +export function ClickableContainer( + props: React.PropsWithChildren<{ onClick?: () => void }>, +) { + return ( +
+ {props.children} +
+ ); +} diff --git a/src/web/src/index.tsx b/src/web/src/index.tsx index 6b1f3be..35489d7 100644 --- a/src/web/src/index.tsx +++ b/src/web/src/index.tsx @@ -1,18 +1,40 @@ import React from "react"; + +import { View } from "./pages/view"; +import { SignIn } from "./pages/signin"; +import { Landing } from "./pages/landing"; + import ReactDOM from "react-dom/client"; import "@fontsource/roboto-mono/500.css"; import "./index.css"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; -import { View } from "./view"; import { ThemeProvider, createTheme } from "@mui/material/styles"; const theme = createTheme({ typography: { fontFamily: "Roboto Mono", }, + palette: { + mode: "dark", + text: { + primary: "#acacac", + secondary: "#acacac", + }, + primary: { + main: "#841a84", + }, + }, }); const router = createBrowserRouter([ + { + path: "/", + element: , + }, + { + path: "/signin", + element: , + }, { path: "/view/*", element: , diff --git a/src/web/src/pages/landing.tsx b/src/web/src/pages/landing.tsx new file mode 100644 index 0000000..e685740 --- /dev/null +++ b/src/web/src/pages/landing.tsx @@ -0,0 +1,15 @@ +import { InternalLink } from "../components/util"; + +export function Landing() { + return ( +
+

Landing

+
+ Sign In +
+
+ View +
+
+ ); +} diff --git a/src/web/src/pages/signin.tsx b/src/web/src/pages/signin.tsx new file mode 100644 index 0000000..bab6b0b --- /dev/null +++ b/src/web/src/pages/signin.tsx @@ -0,0 +1,97 @@ +import { signIn } from "../api/auth"; + +import { sessionAtom } from "../components/atoms"; + +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Container, Spacer } from "../components/util"; +import { useNavigate } from "react-router-dom"; +import { useSetAtom } from "jotai"; + +export function SignIn() { + const navigate = useNavigate(); + const setSession = useSetAtom(sessionAtom); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + console.log({ + email: data.get("username"), + password: data.get("password"), + }); + + signIn(data.get("username") as string, data.get("password") as string).then( + (token) => { + localStorage.setItem("rat_api_token", token); + setSession({ + token: token, + }); + navigate("/view"); + }, + ); + }; + + return ( +
+
+ + + + + Sign in + + + + + + + + + +
+
+ ); +} diff --git a/src/web/src/pages/view.tsx b/src/web/src/pages/view.tsx new file mode 100644 index 0000000..cb8c1cb --- /dev/null +++ b/src/web/src/pages/view.tsx @@ -0,0 +1,81 @@ +import React from "react"; + +import { NodeContent, ChildNodes } from "../components/parts"; +import { Console } from "../components/console"; +import { + sessionAtom, + nodeAtom, + nodePathAtom, + nodeAstAtom, + childNodesAtom, +} from "../components/atoms"; +import { read } from "../api/node"; + +import { useEffect } from "react"; +import { useAtom, useSetAtom, useAtomValue } from "jotai"; +import { useLoaderData, useNavigate } from "react-router-dom"; + +export function View() { + const navigate = useNavigate(); + + const session = useAtomValue(sessionAtom); + const [node, setNode] = useAtom(nodeAtom); + const setNodeAst = useSetAtom(nodeAstAtom); + const setChildNodes = useSetAtom(childNodesAtom); + const setNodePath = useSetAtom(nodePathAtom); + + const path = useLoaderData() as string; // path from router + + const [error, setError] = React.useState(undefined); + + useEffect(() => { + if (!session) { + navigate("/signin"); + + return; + } + + read(session.token, path) + .then((node) => { + setNode(node); + setNodeAst(node.ast); + setChildNodes(node.childNodes); + setNodePath(node.path); + + document.title = node.name; + }) + .catch((err) => { + if (err.response) { + setError(err.response.data.error); + + return; + } + + setError(err.message); + }); + }, [ + session, + path, + setNode, + setNodeAst, + setChildNodes, + setNodePath, + navigate, + ]); + + if (error) { + return <>{error}; + } + + if (!node) { + return <> ; + } + + return ( + <> + + + + + ); +} diff --git a/src/web/src/types/session.tsx b/src/web/src/types/session.tsx new file mode 100644 index 0000000..2dbafd5 --- /dev/null +++ b/src/web/src/types/session.tsx @@ -0,0 +1,3 @@ +export interface Session { + token: string; +} diff --git a/src/web/src/view.tsx b/src/web/src/view.tsx deleted file mode 100644 index c096ee2..0000000 --- a/src/web/src/view.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from "react"; - -import { NodeContent, ChildNodes } from "./components/parts"; -import { Console } from "./components/console"; -import { - nodeAtom, - nodePathAtom, - nodeAstAtom, - childNodesAtom, -} from "./components/atoms"; - -import { useEffect } from "react"; -import { useAtom, useSetAtom } from "jotai"; -import { useLoaderData } from "react-router-dom"; - -import { read } from "./api/node"; - -export function View() { - const [node, setNode] = useAtom(nodeAtom); - const setNodeAst = useSetAtom(nodeAstAtom); - const setChildNodes = useSetAtom(childNodesAtom); - const setNodePath = useSetAtom(nodePathAtom); - - const path = useLoaderData() as string; // path from router - - useEffect(() => { - read(path).then((node) => { - setNode(node); - setNodeAst(node.ast); - setChildNodes(node.childNodes); - setNodePath(node.path); - - document.title = node.name; - }); - }, [path, setNode, setNodeAst, setChildNodes, setNodePath]); - - if (!node) { - return <> ; - } - - return ( - <> - - - - - ); -}