Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce ReadOnlyFileStore #850

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

programmer04
Copy link

@programmer04 programmer04 commented Nov 27, 2024

What this PR does / why we need it:

Currently, struct Config that represents a docker configuration file can be populated only with the function

Load(configPath string) (*Config, error)

that expects to have a file available on the disk and read it. Sometimes, this is a redundant step and a security risk when file content is available in memory. Here is a real-world example

Hence, to overcome the aforementioned problems, this PR introduces a new type of a store - ReadOnlyFileStore that can be populated from io.Reader and supports only Get(...) method according to the proposal from @Wwwsylvia described in the #850 (comment)

@Wwwsylvia
Copy link
Member

Hi @programmer04, thank you for the PR!

From the original issue Kong/gateway-operator#521, it looks like your scenario involves reading plaintext credentials from regcred without needing to update or delete them. Is that correct?

If so, rather than modifying the original credentials file store implementation, we could introduce something like ReadOnlyFileStore, which allows creating a store from a reader and does not support write operations on the credentials.

To get unblocked, you can consider implementing the ReadOnlyFileStore type in your code base. Since the plan is to support read operations only on the auth entry, the implementation would be much more straightforward than the current implementation in oras-go (no need to worry about concurrent writes, accidental overwrites, etc.).

If we feel like this functionality is necessary to be in oras-go, we can open an issue to track the feature request. What do you think?

@programmer04
Copy link
Author

Hey @Wwwsylvia! Thanks for the response

Yes, you're right. The problem I am trying to resolve is populating an oras credential store robustly directly from in-memory (io.Reader) a plain text representation of standard JSON Docker credentials. It can be read-only, it's sufficient, but it should be as good as FileStore in handling all of the cases, see the below.

Here in FileStore operation Get is implemented like that

// Get retrieves credentials from the store for the given server address.
func (fs *FileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) {
return fs.config.GetCredential(serverAddress)
}

where
// GetAuthConfig returns an auth.Credential for serverAddress.
func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) {
cfg.rwLock.RLock()
defer cfg.rwLock.RUnlock()
authCfgBytes, ok := cfg.authsCache[serverAddress]
if !ok {
// NOTE: the auth key for the server address may have been stored with
// a http/https prefix in legacy config files, e.g. "registry.example.com"
// can be stored as "https://registry.example.com/".
var matched bool
for addr, auth := range cfg.authsCache {
if toHostname(addr) == serverAddress {
matched = true
authCfgBytes = auth
break
}
}
if !matched {
return auth.EmptyCredential, nil
}
}
var authCfg AuthConfig
if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil {
return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err)
}
return authCfg.Credential()
}

contains logic to make it work for all cases.

There is memoryStore, but I don't see a way how easily implement generating such a store from standard JSON Docker credentials than handles all of edge cases, etc. like FileStore with its underlying Config struct that is coupled with actual file by a path field.

The problem is that there is a path field in Config (this structure maps JSON Docker creds). It's hard to break that coupling, this is why I proposed a workaround in PR to avoid breaking anything. Maybe you have an idea of how to tackle it differently?

@Wwwsylvia
Copy link
Member

Wwwsylvia commented Dec 18, 2024

The problem is that there is a path field in Config (this structure maps JSON Docker creds). It's hard to break that coupling, this is why I proposed a workaround in PR to avoid breaking anything. Maybe you have an idea of how to tackle it differently?

The file store and the internal Config struct was designed for reading/writing Docker config files. If read-only is sufficient, we can implement a new store like ReadOnlyFileStore that loads the config from a reader. To support such store, we also need to implement a new Config like ReadOnlyConfig. This way we won't add complexity to the existing implementation and can gain better performance in the read-only scenario.

It would look somewhat like:

type ReadOnlyConfig struct {
	Auths map[string]AuthConfig
}

func LoadFromReader(reader io.Reader) (*ReadOnlyConfig, error) {
	// TODO: read from the reader and decode the content into Auths
	return &ReadOnlyConfig{}, nil
}

func (c *ReadOnlyConfig) GetCredential(serverAddress string) (auth.Credential, error) {
	// TODO: get the credential from Auths with best effort
	return auth.Credential{}, nil
}
type ReadOnlyFileStore struct {
	*config.ReadOnlyConfig
}

func NewReadOnlyFileStoreFromReader(reader io.Reader) (*ReadOnlyFileStore, error) {
	cfg, err := config.LoadFromReader(reader)
	if err != nil {
		return nil, err
	}
	return &ReadOnlyFileStore{ReadOnlyConfig: cfg}, nil
}

func (fs *ReadOnlyFileStore) Get(serverAddress string) (auth.Credential, error) {
	return fs.ReadOnlyConfig.GetCredential(serverAddress)
}

func (fs *ReadOnlyFileStore) Put(serverAddress string, cred auth.Credential) error {
	return errors.New("cannot put credentials in read-only file store")
}

func (fs *ReadOnlyFileStore) Delete(serverAddress string) error {
	return errors.New("cannot delete credentials in read-only file store")
}

@programmer04 programmer04 changed the title feat: allow loading config from io.Reader feat: introduce ReadOnlyFileStore Dec 18, 2024
@programmer04 programmer04 force-pushed the cfg-from-reader branch 4 times, most recently from 5ceabd4 to 30e4961 Compare December 18, 2024 15:01
@programmer04
Copy link
Author

programmer04 commented Dec 18, 2024

Thanks for the detailed guidance @Wwwsylvia I've updated PR (code, title, description) according to the solution proposed by you in #850 (comment) I even tested it with my code Kong/gateway-operator#940. Please take a look


// NewReadOnlyConfig creates a new ReadOnlyConfig from the given reader that contains a standard
// config file content. It returns an error if the content is not in the expected format.
func NewReadOnlyConfig(reader io.Reader) (*ReadOnlyConfig, error) {
Copy link
Member

@Wwwsylvia Wwwsylvia Dec 20, 2024

Choose a reason for hiding this comment

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

nit: Should this be called LoadFromReader to be consistent with Load??

Comment on lines 35 to 57
var content map[string]json.RawMessage
if err := json.NewDecoder(reader).Decode(&content); err != nil {
return nil, err
}
var authsCache map[string]json.RawMessage
if authsBytes, ok := content[configFieldAuths]; ok {
if err := json.Unmarshal(authsBytes, &authsCache); err != nil {
return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err)
}
}

cfg := ReadOnlyConfig{
auths: make(map[string]auth.Credential, len(authsCache)),
}
for serverAddress := range authsCache {
creds, err := getCredentialFromCache(authsCache, serverAddress)
if err != nil {
return nil, err
}
cfg.auths[serverAddress] = creds
}

return &cfg, nil
Copy link
Member

Choose a reason for hiding this comment

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

Since the config is read-only and we don't need to write anything back, we can simply decode the content into auths for read. How about:

func LoadFromReader(reader io.Reader) (*ReadOnlyConfig, error) {
	cfg := &ReadOnlyConfig{}
	if err := json.NewDecoder(reader).Decode(&cfg.auths); err != nil {
		return nil, err
	}

	return cfg, nil
}

Copy link
Author

@programmer04 programmer04 Dec 20, 2024

Choose a reason for hiding this comment

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

Exactly, my reasoning is that since it's read-only, the whole parsing can be done during creation because entries won't change in the runtime so I can have a map of auth.Credential ready for use and validated see the test TestReadOnlyFileStore_Create_fromInvalidConfig. In GetCredential and later Get` there is only access to a map; see also the comment in #850 (comment) and please tell me what you think. I can adjust my code according to your guidance but I need to understand how to do it

}

// getCredentialsFromCache is a helper function to get the credential for serverAddress from authsCache in a raw format.
func getCredentialFromCache(authsCache map[string]json.RawMessage, serverAddress string) (auth.Credential, error) {
Copy link
Member

Choose a reason for hiding this comment

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

We can refactor this in another way using generics, so that it can be reused by both config and readonly config:
Something like:

func matchAuth[V any](auths map[string]V, serverAddress string) (V, bool) {
	if v, ok := auths[serverAddress]; ok {
		return v, true
	}
	for addr, v := range auths {
		if toHostname(addr) == serverAddress {
			return v, true
		}
	}
	var zero V
	return zero, false
}

Copy link
Author

@programmer04 programmer04 Dec 20, 2024

Choose a reason for hiding this comment

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

It's currently used by both stores. For readonly in LoadFromReader where I perform parsing all entries to auth.Credential because they won't change during the lifetime of such a config, it can be done here. There is info whether the whole config is valid and no computation is needed for getting a credential.

For file backedd config it's used in method GetCredential. They need to be parsed and looked up on each call because they may change.

I moved it to a dedicated file helpers.go for better visibility and adjusted some names

@@ -44,6 +45,9 @@ var (
// ErrBadCredentialFormat is returned by Put() when the credential format
// is bad.
ErrBadCredentialFormat = errors.New("bad credential format")
// ErrReadOnlyStore is returned for operations
// Put(...) and Delete(...) for read-only store.
ErrReadOnlyStore = errors.New("cannot modify content of the read-only store")
Copy link
Member

Choose a reason for hiding this comment

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

nit: How about: "operation not allowed: the store is read-only"?

// ReadOnlyFileStore implements a credentials store using the docker configuration file
// as an input. It supports only Get operation that works in the same way as for standard
// FileStore.
type ReadOnlyFileStore struct {
Copy link
Member

Choose a reason for hiding this comment

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

Should we put this in a separate file for better readability?

Signed-off-by: Jakub Warczarek <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants