Auth: Add SignedIn user interface NamespacedID (#72944)

* wip

* scope active user to 1 org

* remove TODOs

* add render auth namespace

* import cycle fix

* make condition more readable

* convert Evaluate to user Requester

* only use active OrgID for SearchUserPermissions

* add cache key to interface definition

* change final SignedInUsers to interface

* fix api key managed roles fetch

* fix anon auth id parsing

* Update pkg/services/accesscontrol/acimpl/accesscontrol.go

Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>

---------

Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
This commit is contained in:
Jo 2023-08-09 09:35:50 +02:00 committed by GitHub
parent 144e4887ee
commit bd1a856d33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 128 additions and 55 deletions

View File

@ -6,6 +6,7 @@ import (
"strings"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
@ -14,7 +15,7 @@ import (
type AccessControl interface {
// Evaluate evaluates access to the given resources.
Evaluate(ctx context.Context, user *user.SignedInUser, evaluator Evaluator) (bool, error)
Evaluate(ctx context.Context, user identity.Requester, evaluator Evaluator) (bool, error)
// RegisterScopeAttributeResolver allows the caller to register a scope resolver for a
// specific scope prefix (ex: datasources:name:)
RegisterScopeAttributeResolver(prefix string, resolver ScopeAttributeResolver)
@ -25,11 +26,11 @@ type AccessControl interface {
type Service interface {
registry.ProvidesUsageStats
// GetUserPermissions returns user permissions with only action and scope fields set.
GetUserPermissions(ctx context.Context, user *user.SignedInUser, options Options) ([]Permission, error)
GetUserPermissions(ctx context.Context, user identity.Requester, options Options) ([]Permission, error)
// SearchUsersPermissions returns all users' permissions filtered by an action prefix
SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options SearchOptions) (map[int64][]Permission, error)
SearchUsersPermissions(ctx context.Context, user identity.Requester, options SearchOptions) (map[int64][]Permission, error)
// ClearUserPermissionCache removes the permission cache entry for the given user
ClearUserPermissionCache(user *user.SignedInUser)
ClearUserPermissionCache(user identity.Requester)
// SearchUserPermissions returns single user's permissions filtered by an action prefix or an action
SearchUserPermissions(ctx context.Context, orgID int64, filterOptions SearchOptions) ([]Permission, error)
// DeleteUserPermissions removes all permissions user has in org and all permission to that user
@ -375,10 +376,10 @@ func IsDisabled(cfg *setting.Cfg) bool {
}
// GetOrgRoles returns legacy org roles for a user
func GetOrgRoles(user *user.SignedInUser) []string {
roles := []string{string(user.OrgRole)}
func GetOrgRoles(user identity.Requester) []string {
roles := []string{string(user.GetOrgRole())}
if user.IsGrafanaAdmin {
if user.GetIsGrafanaAdmin() {
roles = append(roles, RoleGrafanaAdmin)
}

View File

@ -9,7 +9,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/setting"
)
@ -28,21 +28,28 @@ type AccessControl struct {
resolvers accesscontrol.Resolvers
}
func (a *AccessControl) Evaluate(ctx context.Context, user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) {
func (a *AccessControl) Evaluate(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
timer := prometheus.NewTimer(metrics.MAccessEvaluationsSummary)
defer timer.ObserveDuration()
metrics.MAccessEvaluationCount.Inc()
if !verifyPermissions(user) {
a.log.Warn("no permissions set for user", "userID", user.UserID, "orgID", user.OrgID, "login", user.Login)
if user == nil || user.IsNil() {
a.log.Warn("no entity set for access control evaluation")
return false, nil
}
namespace, identifier := user.GetNamespacedID()
if len(user.GetPermissions()) == 0 {
a.log.Warn("no permissions set for entity", "namespace", namespace, "id", identifier, "orgID", user.GetOrgID(), "login", user.GetLogin())
return false, nil
}
// Test evaluation without scope resolver first, this will prevent 403 for wildcard scopes when resource does not exist
if evaluator.Evaluate(user.Permissions[user.OrgID]) {
if evaluator.Evaluate(user.GetPermissions()) {
return true, nil
}
resolvedEvaluator, err := evaluator.MutateScopes(ctx, a.resolvers.GetScopeAttributeMutator(user.OrgID))
resolvedEvaluator, err := evaluator.MutateScopes(ctx, a.resolvers.GetScopeAttributeMutator(user.GetOrgID()))
if err != nil {
if errors.Is(err, accesscontrol.ErrResolverNotFound) {
return false, nil
@ -50,7 +57,7 @@ func (a *AccessControl) Evaluate(ctx context.Context, user *user.SignedInUser, e
return false, err
}
return resolvedEvaluator.Evaluate(user.Permissions[user.OrgID]), nil
return resolvedEvaluator.Evaluate(user.GetPermissions()), nil
}
func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) {
@ -60,7 +67,3 @@ func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver a
func (a *AccessControl) IsDisabled() bool {
return accesscontrol.IsDisabled(a.cfg)
}
func verifyPermissions(u *user.SignedInUser) bool {
return u.Permissions != nil || u.Permissions[u.OrgID] != nil
}

View File

@ -21,6 +21,8 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/database"
"github.com/grafana/grafana/pkg/services/accesscontrol/migrator"
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
@ -95,7 +97,7 @@ func (s *Service) GetUsageStats(_ context.Context) map[string]interface{} {
}
// GetUserPermissions returns user permissions based on built-in roles
func (s *Service) GetUserPermissions(ctx context.Context, user *user.SignedInUser, options accesscontrol.Options) ([]accesscontrol.Permission, error) {
func (s *Service) GetUserPermissions(ctx context.Context, user identity.Requester, options accesscontrol.Options) ([]accesscontrol.Permission, error) {
timer := prometheus.NewTimer(metrics.MAccessPermissionsSummary)
defer timer.ObserveDuration()
@ -106,7 +108,7 @@ func (s *Service) GetUserPermissions(ctx context.Context, user *user.SignedInUse
return s.getCachedUserPermissions(ctx, user, options)
}
func (s *Service) getUserPermissions(ctx context.Context, user *user.SignedInUser, options accesscontrol.Options) ([]accesscontrol.Permission, error) {
func (s *Service) getUserPermissions(ctx context.Context, user identity.Requester, options accesscontrol.Options) ([]accesscontrol.Permission, error) {
permissions := make([]accesscontrol.Permission, 0)
for _, builtin := range accesscontrol.GetOrgRoles(user) {
if basicRole, ok := s.roles[builtin]; ok {
@ -114,11 +116,23 @@ func (s *Service) getUserPermissions(ctx context.Context, user *user.SignedInUse
}
}
namespace, identifier := user.GetNamespacedID()
var userID int64
switch namespace {
case authn.NamespaceUser, authn.NamespaceServiceAccount, identity.NamespaceRenderService:
var err error
userID, err = strconv.ParseInt(identifier, 10, 64)
if err != nil {
return nil, err
}
}
dbPermissions, err := s.store.GetUserPermissions(ctx, accesscontrol.GetUserPermissionsQuery{
OrgID: user.OrgID,
UserID: user.UserID,
OrgID: user.GetOrgID(),
UserID: userID,
Roles: accesscontrol.GetOrgRoles(user),
TeamIDs: user.Teams,
TeamIDs: user.GetTeams(),
RolePrefixes: []string{accesscontrol.ManagedRolePrefix, accesscontrol.ExternalServiceRolePrefix},
})
if err != nil {
@ -128,7 +142,7 @@ func (s *Service) getUserPermissions(ctx context.Context, user *user.SignedInUse
return append(permissions, dbPermissions...), nil
}
func (s *Service) getCachedUserPermissions(ctx context.Context, user *user.SignedInUser, options accesscontrol.Options) ([]accesscontrol.Permission, error) {
func (s *Service) getCachedUserPermissions(ctx context.Context, user identity.Requester, options accesscontrol.Options) ([]accesscontrol.Permission, error) {
key, err := permissionCacheKey(user)
if err != nil {
return nil, err
@ -154,7 +168,7 @@ func (s *Service) getCachedUserPermissions(ctx context.Context, user *user.Signe
return permissions, nil
}
func (s *Service) ClearUserPermissionCache(user *user.SignedInUser) {
func (s *Service) ClearUserPermissionCache(user identity.Requester) {
key, err := permissionCacheKey(user)
if err != nil {
return
@ -209,7 +223,7 @@ func (s *Service) IsDisabled() bool {
return accesscontrol.IsDisabled(s.cfg)
}
func permissionCacheKey(user *user.SignedInUser) (string, error) {
func permissionCacheKey(user identity.Requester) (string, error) {
key, err := user.GetCacheKey()
if err != nil {
return "", err
@ -248,7 +262,7 @@ func (s *Service) DeclarePluginRoles(_ context.Context, ID, name string, regs []
}
// SearchUsersPermissions returns all users' permissions filtered by action prefixes
func (s *Service) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64,
func (s *Service) SearchUsersPermissions(ctx context.Context, user identity.Requester,
options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
// Filter ram permissions
basicPermissions := map[string][]accesscontrol.Permission{}
@ -260,21 +274,21 @@ func (s *Service) SearchUsersPermissions(ctx context.Context, user *user.SignedI
}
}
usersRoles, err := s.store.GetUsersBasicRoles(ctx, nil, orgID)
usersRoles, err := s.store.GetUsersBasicRoles(ctx, nil, user.GetOrgID())
if err != nil {
return nil, err
}
// Get managed permissions (DB)
usersPermissions, err := s.store.SearchUsersPermissions(ctx, orgID, options)
usersPermissions, err := s.store.SearchUsersPermissions(ctx, user.GetOrgID(), options)
if err != nil {
return nil, err
}
// helper to filter out permissions the signed in users cannot see
canView := func() func(userID int64) bool {
siuPermissions, ok := user.Permissions[orgID]
if !ok {
siuPermissions := user.GetPermissions()
if len(siuPermissions) == 0 {
return func(_ int64) bool { return false }
}
scopes, ok := siuPermissions[accesscontrol.ActionUsersPermissionsRead]

View File

@ -141,7 +141,7 @@ func benchSearchUsersPermissions(b *testing.B, usersCount, resourceCount int) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
usersPermissions, err := acService.SearchUsersPermissions(context.Background(), siu, 1, accesscontrol.SearchOptions{ActionPrefix: "resources:"})
usersPermissions, err := acService.SearchUsersPermissions(context.Background(), siu, accesscontrol.SearchOptions{ActionPrefix: "resources:"})
require.NoError(b, err)
require.Len(b, usersPermissions, usersCount)
for _, permissions := range usersPermissions {
@ -206,7 +206,7 @@ func benchSearchUsersWithPerm(b *testing.B, usersCount, resourceCount int) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
usersPermissions, err := acService.SearchUsersPermissions(context.Background(), siu, 1,
usersPermissions, err := acService.SearchUsersPermissions(context.Background(), siu,
accesscontrol.SearchOptions{Action: "resources:action2", Scope: "resources:id:1"})
require.NoError(b, err)
require.Len(b, usersPermissions, usersCount)

View File

@ -523,7 +523,7 @@ func TestService_SearchUsersPermissions(t *testing.T) {
}
siu := &user.SignedInUser{OrgID: 2, Permissions: map[int64]map[string][]string{2: tt.siuPermissions}}
got, err := ac.SearchUsersPermissions(ctx, siu, 2, tt.searchOption)
got, err := ac.SearchUsersPermissions(ctx, siu, tt.searchOption)
if tt.wantErr {
require.NotNil(t, err)
return

View File

@ -4,6 +4,7 @@ import (
"context"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/user"
)
@ -23,11 +24,11 @@ func (f FakeService) GetUsageStats(ctx context.Context) map[string]interface{} {
return map[string]interface{}{}
}
func (f FakeService) GetUserPermissions(ctx context.Context, user *user.SignedInUser, options accesscontrol.Options) ([]accesscontrol.Permission, error) {
func (f FakeService) GetUserPermissions(ctx context.Context, user identity.Requester, options accesscontrol.Options) ([]accesscontrol.Permission, error) {
return f.ExpectedPermissions, f.ExpectedErr
}
func (f FakeService) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
func (f FakeService) SearchUsersPermissions(ctx context.Context, user identity.Requester, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
return f.ExpectedUsersPermissions, f.ExpectedErr
}
@ -35,7 +36,7 @@ func (f FakeService) SearchUserPermissions(ctx context.Context, orgID int64, sea
return f.ExpectedFilteredUserPermissions, f.ExpectedErr
}
func (f FakeService) ClearUserPermissionCache(user *user.SignedInUser) {}
func (f FakeService) ClearUserPermissionCache(user identity.Requester) {}
func (f FakeService) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error {
return f.ExpectedErr
@ -69,7 +70,7 @@ type FakeAccessControl struct {
ExpectedEvaluate bool
}
func (f FakeAccessControl) Evaluate(ctx context.Context, user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) {
func (f FakeAccessControl) Evaluate(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return f.ExpectedEvaluate, f.ExpectedErr
}

View File

@ -82,7 +82,7 @@ func (api *AccessControlAPI) searchUsersPermissions(c *contextmodel.ReqContext)
}
// Compute metadata
permissions, err := api.Service.SearchUsersPermissions(c.Req.Context(), c.SignedInUser, c.OrgID, searchOptions)
permissions, err := api.Service.SearchUsersPermissions(c.Req.Context(), c.SignedInUser, searchOptions)
if err != nil {
return response.Error(http.StatusInternalServerError, "could not get org user permissions", err)
}

View File

@ -45,7 +45,7 @@ func Filter(user identity.Requester, sqlID, prefix string, actions ...string) (S
wildcards := 0
result := make(map[interface{}]int)
for _, a := range actions {
ids, hasWildcard := ParseScopes(prefix, user.GetPermissions(user.GetOrgID())[a])
ids, hasWildcard := ParseScopes(prefix, user.GetPermissions()[a])
if hasWildcard {
wildcards += 1
continue

View File

@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/user"
)
@ -101,7 +102,8 @@ func (m *Mock) WithBuiltInRoles(builtInRoles []string) *Mock {
// Evaluate evaluates access to the given resource.
// This mock uses GetUserPermissions to then call the evaluator Evaluate function.
func (m *Mock) Evaluate(ctx context.Context, usr *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) {
func (m *Mock) Evaluate(ctx context.Context, us identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
usr := us.(*user.SignedInUser)
m.Calls.Evaluate = append(m.Calls.Evaluate, []interface{}{ctx, usr, evaluator})
// Use override if provided
if m.EvaluateFunc != nil {
@ -138,7 +140,8 @@ func (m *Mock) Evaluate(ctx context.Context, usr *user.SignedInUser, evaluator a
// GetUserPermissions returns user permissions.
// This mock return m.permissions unless an override is provided.
func (m *Mock) GetUserPermissions(ctx context.Context, user *user.SignedInUser, opts accesscontrol.Options) ([]accesscontrol.Permission, error) {
func (m *Mock) GetUserPermissions(ctx context.Context, usr identity.Requester, opts accesscontrol.Options) ([]accesscontrol.Permission, error) {
user := usr.(*user.SignedInUser)
m.Calls.GetUserPermissions = append(m.Calls.GetUserPermissions, []interface{}{ctx, user, opts})
// Use override if provided
if m.GetUserPermissionsFunc != nil {
@ -148,7 +151,8 @@ func (m *Mock) GetUserPermissions(ctx context.Context, user *user.SignedInUser,
return m.permissions, nil
}
func (m *Mock) ClearUserPermissionCache(user *user.SignedInUser) {
func (m *Mock) ClearUserPermissionCache(usr identity.Requester) {
user := usr.(*user.SignedInUser)
m.Calls.ClearUserPermissionCache = append(m.Calls.ClearUserPermissionCache, []interface{}{user})
// Use override if provided
if m.ClearUserPermissionCacheFunc != nil {
@ -222,11 +226,12 @@ func (m *Mock) DeleteUserPermissions(ctx context.Context, orgID, userID int64) e
}
// SearchUsersPermissions returns all users' permissions filtered by an action prefix
func (m *Mock) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
m.Calls.SearchUsersPermissions = append(m.Calls.SearchUsersPermissions, []interface{}{ctx, user, orgID, options})
func (m *Mock) SearchUsersPermissions(ctx context.Context, usr identity.Requester, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
user := usr.(*user.SignedInUser)
m.Calls.SearchUsersPermissions = append(m.Calls.SearchUsersPermissions, []interface{}{ctx, user, options})
// Use override if provided
if m.SearchUsersPermissionsFunc != nil {
return m.SearchUsersPermissionsFunc(ctx, user, orgID, options)
return m.SearchUsersPermissionsFunc(ctx, user, usr.GetOrgID(), options)
}
return nil, nil
}

View File

@ -1,9 +1,26 @@
package identity
import "github.com/grafana/grafana/pkg/models/roletype"
const (
NamespaceUser = "user"
NamespaceAPIKey = "api-key"
NamespaceServiceAccount = "service-account"
NamespaceAnonymous = "anonymous"
NamespaceRenderService = "render"
)
type Requester interface {
GetIsGrafanaAdmin() bool
GetLogin() string
GetOrgID() int64
GetPermissions(orgID int64) map[string][]string
GetPermissions() map[string][]string
GetTeams() []int64
GetOrgRole() roletype.RoleType
GetNamespacedID() (string, string)
IsNil() bool
// Legacy
GetCacheKey() (string, error)
HasUniqueId() bool
}

View File

@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/middleware/cookies"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
@ -172,9 +173,9 @@ type Redirect struct {
}
const (
NamespaceUser = "user"
NamespaceAPIKey = "api-key"
NamespaceServiceAccount = "service-account"
NamespaceUser = identity.NamespaceUser
NamespaceAPIKey = identity.NamespaceAPIKey
NamespaceServiceAccount = identity.NamespaceServiceAccount
)
type Identity struct {

View File

@ -615,7 +615,7 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) {
if !hasWildcardScope(tt.query.User, accesscontrol.ActionOrgUsersRead) {
for _, u := range result.OrgUsers {
assert.Contains(t, tt.query.User.GetPermissions(tt.query.User.GetOrgID())[accesscontrol.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserID))
assert.Contains(t, tt.query.User.GetPermissions()[accesscontrol.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserID))
}
}
})
@ -647,7 +647,7 @@ func seedOrgUsers(t *testing.T, orgUserStore store, store *sqlstore.SQLStore, nu
}
func hasWildcardScope(user identity.Requester, action string) bool {
for _, scope := range user.GetPermissions(user.GetOrgID())[action] {
for _, scope := range user.GetPermissions()[action] {
if strings.HasSuffix(scope, ":*") {
return true
}
@ -792,7 +792,7 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) {
if !hasWildcardScope(tt.query.User, accesscontrol.ActionOrgUsersRead) {
for _, u := range result.OrgUsers {
assert.Contains(t, tt.query.User.GetPermissions(tt.query.User.GetOrgID())[accesscontrol.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserID))
assert.Contains(t, tt.query.User.GetPermissions()[accesscontrol.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserID))
}
}
})

View File

@ -5,6 +5,7 @@ import (
"time"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
)
type SignedInUser struct {
@ -105,12 +106,42 @@ func (u *SignedInUser) GetOrgID() int64 {
return u.OrgID
}
func (u *SignedInUser) GetPermissions(orgID int64) map[string][]string {
func (u *SignedInUser) GetPermissions() map[string][]string {
if u.Permissions == nil {
return make(map[string][]string)
}
return u.Permissions[orgID]
if u.Permissions[u.GetOrgID()] == nil {
return make(map[string][]string)
}
return u.Permissions[u.GetOrgID()]
}
func (u *SignedInUser) GetTeams() []int64 {
return u.Teams
}
func (u *SignedInUser) GetOrgRole() roletype.RoleType {
return u.OrgRole
}
func (u *SignedInUser) GetNamespacedID() (string, string) {
switch {
case u.ApiKeyID != 0:
return identity.NamespaceAPIKey, fmt.Sprintf("%d", u.ApiKeyID)
case u.IsServiceAccount:
return identity.NamespaceServiceAccount, fmt.Sprintf("%d", u.UserID)
case u.UserID != 0:
return identity.NamespaceUser, fmt.Sprintf("%d", u.UserID)
case u.IsAnonymous:
return identity.NamespaceAnonymous, ""
case u.AuthenticatedBy == "render": //import cycle render
return identity.NamespaceRenderService, fmt.Sprintf("%d", u.UserID)
}
// backwards compatibility
return identity.NamespaceUser, fmt.Sprintf("%d", u.UserID)
}
// FIXME: remove this method once all services are using an interface